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
2 changes: 1 addition & 1 deletion docs/git-draft.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Otherwise if no template is specified and stdin is a TTY, `$EDITOR` will be open
--quit::
Go back to the draft's origin branch, keeping the working directory's current state.
This will delete the draft branch and its upstream.
Generated commits and the draft branch's final state remain available via `ref/drafts`.
Generated commits and the draft branch's final state remain available via `refs/drafts`.

-T::
--templates::
Expand Down
127 changes: 93 additions & 34 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ requires-python = ">=3.12"
dependencies = [
"docopt-ng (>=0.9,<0.10)",
"jinja2 (>=3.1.5,<4)",
"msgspec (>=0.19.0,<0.20.0)",
"prettytable (>=3.15.1,<4)",
"xdg-base-dirs (>=6.0.2,<7)",
"yaspin (>=3.1.0,<4)",
Expand Down Expand Up @@ -39,7 +40,7 @@ python = ">=3.12,<4"

[tool.poetry.group.dev.dependencies]
coverage = "^7.4.4"
mypy = "^1.2.1"
mypy = "^1.18.1"
poethepoet = "^0.25.0"
pytest = "^8.2.0"
pytest-asyncio = "^1.0.0"
Expand Down Expand Up @@ -88,6 +89,7 @@ show_missing = true

[tool.mypy]
disable_error_code = "import-untyped"
enable_error_code = "exhaustive-match"

[tool.pytest.ini_options]
log_level = "DEBUG"
Expand Down
8 changes: 4 additions & 4 deletions src/git_draft/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import logging

from .bots import Action, Bot, Goal
from .toolbox import Toolbox
from .bots import ActionSummary, Bot, Goal, UserFeedback, Worktree


__all__ = [
"Action",
"ActionSummary",
"Bot",
"Goal",
"Toolbox",
"UserFeedback",
"Worktree",
]

logging.getLogger(__name__).addHandler(logging.NullHandler())
29 changes: 17 additions & 12 deletions src/git_draft/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,11 @@
import sys

from .bots import load_bot
from .common import (
PROGRAM,
Config,
Progress,
UnreachableError,
ensure_state_home,
)
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
from .drafter import Drafter, DraftMergeStrategy
from .editor import open_editor
from .git import Repo
from .progress import Progress
from .prompt import (
PromptMetadata,
TemplatedPrompt,
Expand All @@ -41,6 +36,11 @@ def new_parser() -> optparse.OptionParser:

parser.disable_interspersed_args()

parser.add_option(
"--batch",
help="disable interactive feedback",
action="store_true",
)
parser.add_option(
"--log-path",
help="show log path and exit",
Expand Down Expand Up @@ -167,7 +167,11 @@ async def run() -> None: # noqa: PLR0912 PLR0915
datefmt="%m-%d %H:%M",
)

progress = Progress.dynamic() if sys.stdin.isatty() else Progress.static()
progress = (
Progress.dynamic()
if sys.stdin.isatty() and not opts.batch
else Progress.static()
)
repo = Repo.enclosing(Path(opts.root) if opts.root else Path.cwd())
drafter = Drafter.create(repo, Store.persistent(), progress)
match getattr(opts, "command", "new"):
Expand Down Expand Up @@ -200,10 +204,10 @@ async def run() -> None: # noqa: PLR0912 PLR0915

accept = Accept(opts.accept or 0)
await drafter.generate_draft(
prompt,
bot,
prompt_transform=open_editor if editable else None,
prompt=prompt,
bot=bot,
merge_strategy=accept.merge_strategy(),
prompt_transform=open_editor if editable else None,
)
if accept == Accept.MERGE_THEN_QUIT:
# TODO: Refuse to quit on pending question?
Expand Down Expand Up @@ -235,7 +239,8 @@ def main() -> None:
asyncio.run(run())
except Exception as err:
_logger.exception("Program failed.")
print(f"Error: {err}", file=sys.stderr)
message = str(err) or "See logs for more information"
print(f"Error: {message}", file=sys.stderr)
sys.exit(1)


Expand Down
13 changes: 5 additions & 8 deletions src/git_draft/bots/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
"""Bot interfaces and built-in implementations

* https://aider.chat/docs/leaderboards/
"""
"""Bot interfaces and built-in implementations"""

import importlib
import os
import sys

from ..common import BotConfig, reindent
from ..toolbox import Toolbox
from .common import Action, Bot, Goal
from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree


__all__ = [
"Action",
"ActionSummary",
"Bot",
"Goal",
"Toolbox",
"UserFeedback",
"Worktree",
]


Expand Down
58 changes: 52 additions & 6 deletions src/git_draft/bots/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

from collections.abc import Sequence
import contextlib
import dataclasses
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Protocol

from ..common import ensure_state_home, qualified_class_name
from ..toolbox import Toolbox


@dataclasses.dataclass(frozen=True)
Expand All @@ -17,8 +19,51 @@ class Goal:
# TODO: Add timeout.


class Worktree(Protocol):
"""File operations

Implementations may not be thread-safe. Concurrent operations should be
serialized by the caller.
"""

def list_files(self) -> Sequence[PurePosixPath]:
"""List all files"""

def read_file(self, path: PurePosixPath) -> str | None:
"""Get a file's contents"""

def write_file(self, path: PurePosixPath, contents: str) -> None:
"""Update a file's contents"""

def delete_file(self, path: PurePosixPath) -> None:
"""Remove a file"""

def rename_file(
self, src_path: PurePosixPath, dst_path: PurePosixPath
) -> None:
"""Move a file"""

def edit_files(self) -> contextlib.AbstractContextManager[Path]:
"""Return path to a temporary folder with editable copies of all files

Any updates are synced back to the work tree when the context exits.
Other operations should not be performed concurrently as they may be
stale or lost.
"""


class UserFeedback(Protocol):
"""User interactions"""

def notify(self, update: str) -> None:
"""Report progress to the user"""

def ask(self, question: str) -> str:
"""Request additional information from the user"""


@dataclasses.dataclass
class Action:
class ActionSummary:
"""End-of-action statistics

This dataclass is not frozen to allow bot implementors to populate its
Expand All @@ -28,7 +73,6 @@ class Action:
title: str | None = None
request_count: int | None = None
token_count: int | None = None
question: str | None = None

def increment_request_count(self, n: int = 1, init: bool = False) -> None:
self._increment("request_count", n, init)
Expand Down Expand Up @@ -66,6 +110,8 @@ def state_folder_path(cls, ensure_exists: bool = False) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path

async def act(self, goal: Goal, toolbox: Toolbox) -> Action:
"""Runs the bot, striving to achieve the goal with the given toolbox"""
async def act(
self, goal: Goal, tree: Worktree, feedback: UserFeedback
) -> ActionSummary:
"""Runs the bot, striving to achieve the goal"""
raise NotImplementedError()
Loading