feat: projects and worktree management (#62)

This commit is contained in:
banteg
2026-01-07 17:45:05 +04:00
committed by GitHub
parent 1178b738df
commit aa078258ea
28 changed files with 1735 additions and 144 deletions
+18
View File
@@ -104,3 +104,21 @@ def test_translate_command_execution_allows_null_exit_code() -> None:
assert isinstance(out[0], ActionEvent)
assert out[0].ok is True
assert out[0].action.detail["exit_code"] is None
def test_translate_file_change_normalizes_changes() -> None:
evt = {
"type": "item.completed",
"item": {
"id": "item_6",
"type": "file_change",
"changes": [{"path": "README.md", "kind": "update"}],
"status": "completed",
},
}
out = _translate_event(evt)
assert len(out) == 1
assert isinstance(out[0], ActionEvent)
changes = out[0].action.detail["changes"]
assert changes == [{"path": "README.md", "kind": "update"}]
+31
View File
@@ -334,6 +334,37 @@ async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
assert transport.send_calls[-1]["options"].replace == transport.send_calls[0]["ref"]
@pytest.mark.anyio
async def test_final_message_includes_ctx_line() -> None:
transport = _FakeTransport()
clock = _FakeClock()
session_id = "123e4567-e89b-12d3-a456-426614174000"
runner = ScriptRunner(
[Return(answer="done")],
engine=CODEX_ENGINE,
resume_value=session_id,
)
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
await handle_message(
cfg,
runner=runner,
incoming=IncomingMessage(channel_id=123, message_id=42, text="do it"),
resume_token=None,
context_line="`ctx: takopi @ feat/api`",
clock=clock,
)
assert transport.send_calls
final_text = transport.send_calls[-1]["message"].text
assert "`ctx: takopi @ feat/api`" in final_text
assert "codex resume" in final_text.lower()
@pytest.mark.anyio
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
transport = _FakeTransport()
+52
View File
@@ -16,6 +16,7 @@ from takopi.markdown import (
from takopi.model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent
from takopi.progress import ProgressTracker
from takopi.telegram.render import render_markdown
from takopi.utils.paths import reset_run_base_dir, set_run_base_dir
from tests.factories import (
action_completed,
action_started,
@@ -119,6 +120,40 @@ def test_file_change_renders_relative_paths_inside_cwd() -> None:
)
def test_file_change_renders_change_objects(tmp_path: Path) -> None:
base = tmp_path / "repo"
base.mkdir()
abs_path = str(base / "changelog.md")
token = set_run_base_dir(base)
try:
out = render_event_cli(
action_completed(
"f-obj",
"file_change",
"ignored",
ok=True,
detail={"changes": [SimpleNamespace(path=abs_path, kind="update")]},
)
)
finally:
reset_run_base_dir(token)
assert any("files: update `changelog.md`" in line for line in out)
def test_file_change_title_relativizes_absolute_title(tmp_path: Path) -> None:
base = tmp_path / "repo"
base.mkdir()
abs_path = str(base / "changelog.md")
token = set_run_base_dir(base)
try:
out = render_event_cli(
action_completed("f-abs", "file_change", abs_path, ok=True)
)
finally:
reset_run_base_dir(token)
assert any("files: `changelog.md`" in line for line in out)
def test_progress_renderer_renders_progress_and_final() -> None:
tracker = ProgressTracker(engine="codex")
for evt in SAMPLE_EVENTS:
@@ -145,6 +180,23 @@ def test_progress_renderer_renders_progress_and_final() -> None:
)
def test_progress_renderer_footer_includes_ctx_before_resume() -> None:
tracker = ProgressTracker(engine="codex")
for evt in SAMPLE_EVENTS:
tracker.note_event(evt)
state = tracker.snapshot(
resume_formatter=_format_resume,
context_line="`ctx: z80 @ feat/name`",
)
formatter = MarkdownFormatter(max_actions=5)
parts = formatter.render_progress_parts(state, elapsed_s=0.0)
assert parts.footer == (
"`ctx: z80 @ feat/name`"
f"{HARD_BREAK}`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`"
)
def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None:
tracker = ProgressTracker(engine="codex")
events = [
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
from takopi.utils.git import resolve_default_base, resolve_main_worktree_root
def test_resolve_main_worktree_root_returns_none_when_no_git(monkeypatch) -> None:
monkeypatch.setattr("takopi.utils.git.git_stdout", lambda *args, **kwargs: None)
assert resolve_main_worktree_root(Path("/tmp")) is None
def test_resolve_main_worktree_root_prefers_common_dir_parent(monkeypatch) -> None:
base = Path("/repo")
def _fake_stdout(args, **kwargs):
if args[:2] == ["rev-parse", "--path-format=absolute"]:
return str(base / ".git")
if args == ["rev-parse", "--is-bare-repository"]:
return "false"
return None
monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout)
assert resolve_main_worktree_root(base / ".worktrees" / "feature") == base
def test_resolve_main_worktree_root_returns_cwd_for_bare_repo(monkeypatch) -> None:
cwd = Path("/bare-repo")
def _fake_stdout(args, **kwargs):
if args[:2] == ["rev-parse", "--path-format=absolute"]:
return str(cwd / "repo.git")
if args == ["rev-parse", "--is-bare-repository"]:
return "true"
return None
monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout)
assert resolve_main_worktree_root(cwd) == cwd
def test_resolve_default_base_prefers_master_over_main(monkeypatch) -> None:
def _fake_stdout(args, **kwargs):
if args[:2] == ["symbolic-ref", "-q"]:
return None
if args == ["branch", "--show-current"]:
return None
return None
def _fake_ok(args, **kwargs):
if args == ["show-ref", "--verify", "--quiet", "refs/heads/master"]:
return True
if args == ["show-ref", "--verify", "--quiet", "refs/heads/main"]:
return True
return False
monkeypatch.setattr("takopi.utils.git.git_stdout", _fake_stdout)
monkeypatch.setattr("takopi.utils.git.git_ok", _fake_ok)
assert resolve_default_base(Path("/repo")) == "master"
+17 -1
View File
@@ -2,7 +2,12 @@ from __future__ import annotations
from pathlib import Path
from takopi.utils.paths import relativize_command, relativize_path
from takopi.utils.paths import (
relativize_command,
relativize_path,
reset_run_base_dir,
set_run_base_dir,
)
def test_relativize_command_rewrites_cwd_paths(tmp_path: Path) -> None:
@@ -33,3 +38,14 @@ def test_relativize_path_inside_base(tmp_path: Path) -> None:
base.mkdir()
value = str(base / "src" / "app.py")
assert relativize_path(value, base_dir=base) == "src/app.py"
def test_relativize_path_uses_run_base_dir(tmp_path: Path) -> None:
base = tmp_path / "repo"
base.mkdir()
token = set_run_base_dir(base)
try:
value = str(base / "src" / "app.py")
assert relativize_path(value) == "src/app.py"
finally:
reset_run_base_dir(token)
+49
View File
@@ -0,0 +1,49 @@
from pathlib import Path
import pytest
from typer.testing import CliRunner
from takopi import cli
from takopi.config import ConfigError, parse_projects_config
def test_parse_projects_rejects_engine_alias() -> None:
config = {"projects": {"codex": {"path": "/tmp/repo"}}}
with pytest.raises(ConfigError, match="aliases must not match engine ids"):
parse_projects_config(
config,
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
)
def test_parse_projects_default_project_must_exist() -> None:
config = {"default_project": "z80", "projects": {}}
with pytest.raises(ConfigError, match="default_project"):
parse_projects_config(
config,
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=("cancel",),
)
def test_init_writes_project(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml"
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
monkeypatch.setattr(cli, "resolve_default_base", lambda _: "main")
repo_path = tmp_path / "repo"
repo_path.mkdir()
monkeypatch.chdir(repo_path)
runner = CliRunner()
result = runner.invoke(cli.app, ["init", "z80"])
assert result.exit_code == 0
saved = config_path.read_text(encoding="utf-8")
assert "[projects.z80]" in saved
assert 'worktrees_dir = ".worktrees"' in saved
assert 'default_engine = "codex"' in saved
assert 'worktree_base = "main"' in saved
+112 -42
View File
@@ -1,3 +1,5 @@
from pathlib import Path
import anyio
import pytest
@@ -7,16 +9,19 @@ from takopi.telegram.bridge import (
_build_bot_commands,
_handle_cancel,
_is_cancel_command,
_resolve_message,
_send_with_resume,
_strip_engine_command,
run_main_loop,
)
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
from takopi.markdown import MarkdownPresenter
from takopi.model import EngineId, ResumeToken
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
from takopi.transport import MessageRef, RenderedMessage, SendOptions
from takopi.transport import IncomingMessage, MessageRef, RenderedMessage, SendOptions
CODEX_ENGINE = EngineId("codex")
@@ -354,7 +359,15 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
async def test_handle_cancel_without_reply_prompts_user() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
msg = {"chat": {"id": 123}, "message_id": 10}
msg = IncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="/cancel",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
@@ -367,11 +380,15 @@ async def test_handle_cancel_without_reply_prompts_user() -> None:
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"text": "no message id"},
}
msg = IncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="/cancel",
reply_to_message_id=None,
reply_to_text="no message id",
sender_id=123,
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
@@ -385,11 +402,15 @@ async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
progress_id = 99
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
msg = IncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="/cancel",
reply_to_message_id=progress_id,
reply_to_text=None,
sender_id=123,
)
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
@@ -403,11 +424,15 @@ async def test_handle_cancel_cancels_running_task() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
progress_id = 42
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
msg = IncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="/cancel",
reply_to_message_id=progress_id,
reply_to_text=None,
sender_id=123,
)
running_task = RunningTask()
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task}
@@ -423,11 +448,15 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
cfg = _make_cfg(transport)
task_first = RunningTask()
task_second = RunningTask()
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": 1},
}
msg = IncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="/cancel",
reply_to_message_id=1,
reply_to_text=None,
sender_id=123,
)
running_tasks = {
MessageRef(channel_id=123, message_id=1): task_first,
MessageRef(channel_id=123, message_id=2): task_second,
@@ -446,16 +475,46 @@ def test_cancel_command_accepts_extra_text() -> None:
assert _is_cancel_command("/cancelled") is False
def test_resolve_message_accepts_backticked_ctx_line() -> None:
router = _make_router(ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE))
projects = ProjectsConfig(
projects={
"takopi": ProjectConfig(
alias="takopi",
path=Path("."),
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
resolved = _resolve_message(
text="do it",
reply_text="`ctx: takopi @ feat/api`",
router=router,
projects=projects,
)
assert resolved.prompt == "do it"
assert resolved.resume_token is None
assert resolved.engine_override is None
assert resolved.context == RunContext(project="takopi", branch="feat/api")
@pytest.mark.anyio
async def test_send_with_resume_waits_for_token() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken | None]] = []
sent: list[tuple[int, int, str, ResumeToken, RunContext | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
chat_id: int,
user_msg_id: int,
text: str,
resume: ResumeToken,
context: RunContext | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
sent.append((chat_id, user_msg_id, text, resume, context))
running_task = RunningTask()
@@ -476,7 +535,7 @@ async def test_send_with_resume_waits_for_token() -> None:
)
assert sent == [
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"))
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"), None)
]
assert transport.send_calls == []
@@ -485,12 +544,16 @@ async def test_send_with_resume_waits_for_token() -> None:
async def test_send_with_resume_reports_when_missing() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken | None]] = []
sent: list[tuple[int, int, str, ResumeToken, RunContext | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
chat_id: int,
user_msg_id: int,
text: str,
resume: ResumeToken,
context: RunContext | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
sent.append((chat_id, user_msg_id, text, resume, context))
running_task = RunningTask()
running_task.done.set()
@@ -538,22 +601,29 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
)
async def poller(_cfg: TelegramBridgeConfig):
yield {
"message_id": 1,
"text": "first",
"chat": {"id": 123},
"from": {"id": 123},
}
yield IncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="first",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
)
await progress_ready.wait()
assert transport.progress_ref is not None
assert isinstance(transport.progress_ref.message_id, int)
reply_id = transport.progress_ref.message_id
reply_ready.set()
yield {
"message_id": 2,
"text": "followup",
"chat": {"id": 123},
"from": {"id": 123},
"reply_to_message": {"message_id": transport.progress_ref.message_id},
}
yield IncomingMessage(
transport="telegram",
chat_id=123,
message_id=2,
text="followup",
reply_to_message_id=reply_id,
reply_to_text=None,
sender_id=123,
)
await stop_polling.wait()
async with anyio.create_task_group() as tg:
+47
View File
@@ -0,0 +1,47 @@
from takopi.telegram import parse_incoming_update
def test_parse_incoming_update_maps_fields() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"text": "hello",
"chat": {"id": 123},
"from": {"id": 99},
"reply_to_message": {"message_id": 5, "text": "prev"},
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert msg.transport == "telegram"
assert msg.chat_id == 123
assert msg.message_id == 10
assert msg.text == "hello"
assert msg.reply_to_message_id == 5
assert msg.reply_to_text == "prev"
assert msg.sender_id == 99
assert msg.raw == update["message"]
def test_parse_incoming_update_filters_non_matching_chat() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"text": "hello",
"chat": {"id": 123},
},
}
assert parse_incoming_update(update, chat_id=999) is None
def test_parse_incoming_update_filters_non_text() -> None:
update = {
"update_id": 1,
"message": {"message_id": 10, "chat": {"id": 123}},
}
assert parse_incoming_update(update, chat_id=123) is None
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.context import RunContext
from takopi.worktrees import WorktreeError, ensure_worktree, resolve_run_cwd
def _projects_config(path: Path) -> ProjectsConfig:
return ProjectsConfig(
projects={
"z80": ProjectConfig(
alias="z80",
path=path,
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
def test_resolve_run_cwd_uses_project_root(tmp_path: Path) -> None:
projects = _projects_config(tmp_path)
ctx = RunContext(project="z80")
assert resolve_run_cwd(ctx, projects=projects) == tmp_path
def test_resolve_run_cwd_rejects_invalid_branch(tmp_path: Path) -> None:
projects = _projects_config(tmp_path)
ctx = RunContext(project="z80", branch="../oops")
with pytest.raises(WorktreeError, match="branch name"):
resolve_run_cwd(ctx, projects=projects)
def test_ensure_worktree_creates_from_base(monkeypatch, tmp_path: Path) -> None:
project = ProjectConfig(
alias="z80",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
)
calls: list[list[str]] = []
monkeypatch.setattr("takopi.worktrees.git_ok", lambda *args, **kwargs: False)
monkeypatch.setattr("takopi.worktrees.resolve_default_base", lambda *_: "main")
def _fake_git_run(args, cwd):
calls.append(list(args))
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("takopi.worktrees.git_run", _fake_git_run)
worktree_path = ensure_worktree(project, "feat/name")
assert worktree_path == tmp_path / ".worktrees" / "feat" / "name"
assert calls == [["worktree", "add", "-b", "feat/name", str(worktree_path), "main"]]