feat: projects and worktree management (#62)
This commit is contained in:
@@ -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"}]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"]]
|
||||
Reference in New Issue
Block a user