"""KIN-103 — Regression + unit tests for worktrees_enabled feature. Covers: 1. core/worktree.py — create_worktree, merge_worktree, cleanup_worktree, ensure_gitignore 2. agents/runner.py run_pipeline — worktree lifecycle (create, merge, cleanup) - eligible role + worktrees_enabled=True → worktree created - non-eligible role → no worktree - worktrees_enabled=False → no worktree - success path → merge + cleanup called - failure path → cleanup called without merge - create_worktree returns None → graceful fallback, no crash - non-git directory (create_worktree fails) → pipeline continues normally """ import subprocess from pathlib import Path from unittest.mock import MagicMock, call, patch import pytest from core.db import init_db from core import models # ───────────────────────────────────────────────────────────────────────────── # Shared fixtures # ───────────────────────────────────────────────────────────────────────────── @pytest.fixture def conn(): c = init_db(":memory:") models.create_project(c, "wt1", "WT Project", "/projects/wtproject", tech_stack=["python"]) models.create_task(c, "WT1-001", "wt1", "Fix worktree bug", brief={"route_type": "debug"}) yield c c.close() @pytest.fixture def conn_wt_enabled(conn): """Project with worktrees_enabled=1.""" conn.execute("UPDATE projects SET worktrees_enabled=1 WHERE id='wt1'") conn.commit() return conn def _ok_run(): m = MagicMock() m.returncode = 0 m.stdout = "" m.stderr = "" return m def _fail_run(stderr="git error"): m = MagicMock() m.returncode = 1 m.stdout = "" m.stderr = stderr return m def _claude_ok(): import json m = MagicMock() m.returncode = 0 m.stdout = json.dumps({"result": "done"}) m.stderr = "" return m # ───────────────────────────────────────────────────────────────────────────── # 1. core/worktree.py — create_worktree # ───────────────────────────────────────────────────────────────────────────── class TestCreateWorktree: @patch("subprocess.run") def test_create_worktree_success_returns_path(self, mock_run, tmp_path): """create_worktree вызывает git worktree add и возвращает путь.""" from core.worktree import create_worktree mock_run.return_value = _ok_run() result = create_worktree(str(tmp_path), "WT1-001", "backend_dev") assert result is not None assert "WT1-001" in result assert "backend_dev" in result @patch("subprocess.run") def test_create_worktree_calls_git_worktree_add(self, mock_run, tmp_path): """create_worktree вызывает git worktree add с корректными аргументами.""" from core.worktree import create_worktree mock_run.return_value = _ok_run() create_worktree(str(tmp_path), "WT1-001", "debugger") cmd = mock_run.call_args[0][0] assert "worktree" in cmd assert "add" in cmd @patch("subprocess.run") def test_create_worktree_git_fail_returns_none(self, mock_run, tmp_path): """Если git возвращает non-zero returncode — create_worktree возвращает None.""" from core.worktree import create_worktree mock_run.return_value = _fail_run("fatal: not a git repo") result = create_worktree(str(tmp_path), "WT1-001", "debugger") assert result is None @patch("subprocess.run", side_effect=Exception("git not found")) def test_create_worktree_exception_returns_none(self, mock_run, tmp_path): """При исключении create_worktree возвращает None, не поднимает.""" from core.worktree import create_worktree result = create_worktree(str(tmp_path), "WT1-001", "debugger") assert result is None @patch("subprocess.run") def test_create_worktree_sanitizes_step_name(self, mock_run, tmp_path): """Слэши и пробелы в step_name заменяются на _ в branch name.""" from core.worktree import create_worktree mock_run.return_value = _ok_run() result = create_worktree(str(tmp_path), "WT1-001", "role/with spaces") assert result is not None assert "/" not in Path(result).name assert "role_with_spaces" in Path(result).name # ───────────────────────────────────────────────────────────────────────────── # 2. core/worktree.py — merge_worktree # ───────────────────────────────────────────────────────────────────────────── class TestMergeWorktree: @patch("subprocess.run") def test_merge_worktree_success_returns_dict(self, mock_run, tmp_path): """Успешный merge возвращает {success: True, conflicts: [], merged_files: [...]}.""" from core.worktree import merge_worktree worktree_path = tmp_path / ".kin_worktrees" / "WT1-001-debugger" worktree_path.mkdir(parents=True) # First call: merge succeeds; second: diff mock_run.side_effect = [ _ok_run(), # git merge --no-ff _ok_run(), # git diff HEAD~1 HEAD --name-only ] result = merge_worktree(str(worktree_path), str(tmp_path)) assert result["success"] is True assert result["conflicts"] == [] @patch("subprocess.run") def test_merge_worktree_conflict_returns_conflicts(self, mock_run, tmp_path): """При конфликте merge возвращает {success: False, conflicts: [...]}.""" from core.worktree import merge_worktree worktree_path = tmp_path / ".kin_worktrees" / "WT1-001-debugger" worktree_path.mkdir(parents=True) conflict_diff = MagicMock() conflict_diff.returncode = 0 conflict_diff.stdout = "src/app.py\nsrc/utils.py\n" conflict_diff.stderr = "" abort_result = _ok_run() merge_fail = _fail_run("CONFLICT (content)") mock_run.side_effect = [merge_fail, conflict_diff, abort_result] result = merge_worktree(str(worktree_path), str(tmp_path)) assert result["success"] is False assert "src/app.py" in result["conflicts"] @patch("subprocess.run", side_effect=Exception("git gone")) def test_merge_worktree_exception_returns_failure(self, mock_run, tmp_path): """При исключении merge возвращает {success: False}, не поднимает.""" from core.worktree import merge_worktree worktree_path = tmp_path / ".kin_worktrees" / "WT1-001-debugger" worktree_path.mkdir(parents=True) result = merge_worktree(str(worktree_path), str(tmp_path)) assert result["success"] is False # ───────────────────────────────────────────────────────────────────────────── # 3. core/worktree.py — cleanup_worktree # ───────────────────────────────────────────────────────────────────────────── class TestCleanupWorktree: @patch("subprocess.run") def test_cleanup_calls_worktree_remove_and_branch_delete(self, mock_run, tmp_path): """cleanup_worktree вызывает git worktree remove и git branch -D.""" from core.worktree import cleanup_worktree worktree_path = tmp_path / ".kin_worktrees" / "WT1-001-debugger" mock_run.return_value = _ok_run() cleanup_worktree(str(worktree_path), str(tmp_path)) assert mock_run.call_count == 2 cmds = [call[0][0] for call in mock_run.call_args_list] worktree_remove_cmd = cmds[0] branch_delete_cmd = cmds[1] assert "worktree" in worktree_remove_cmd and "remove" in worktree_remove_cmd assert "branch" in branch_delete_cmd and "-D" in branch_delete_cmd @patch("subprocess.run", side_effect=Exception("permission denied")) def test_cleanup_worktree_exception_does_not_raise(self, mock_run, tmp_path): """cleanup_worktree никогда не поднимает исключение.""" from core.worktree import cleanup_worktree worktree_path = tmp_path / ".kin_worktrees" / "WT1-001-debugger" # Must not raise cleanup_worktree(str(worktree_path), str(tmp_path)) # ───────────────────────────────────────────────────────────────────────────── # 4. core/worktree.py — ensure_gitignore # ───────────────────────────────────────────────────────────────────────────── class TestEnsureGitignore: def test_ensure_gitignore_creates_file_if_not_exists(self, tmp_path): """ensure_gitignore создаёт .gitignore с .kin_worktrees/ если файла нет.""" from core.worktree import ensure_gitignore ensure_gitignore(str(tmp_path)) gitignore = tmp_path / ".gitignore" assert gitignore.exists() assert ".kin_worktrees/" in gitignore.read_text() def test_ensure_gitignore_appends_entry_if_file_exists(self, tmp_path): """ensure_gitignore добавляет .kin_worktrees/ в существующий .gitignore.""" from core.worktree import ensure_gitignore gitignore = tmp_path / ".gitignore" gitignore.write_text("*.pyc\n__pycache__/\n") ensure_gitignore(str(tmp_path)) content = gitignore.read_text() assert ".kin_worktrees/" in content assert "*.pyc" in content # existing entries preserved def test_ensure_gitignore_no_duplicate_if_entry_exists(self, tmp_path): """ensure_gitignore не дублирует запись если уже есть.""" from core.worktree import ensure_gitignore gitignore = tmp_path / ".gitignore" gitignore.write_text(".kin_worktrees/\n") ensure_gitignore(str(tmp_path)) content = gitignore.read_text() assert content.count(".kin_worktrees/") == 1 def test_ensure_gitignore_exception_does_not_raise(self, tmp_path): """ensure_gitignore не поднимает исключение при любых ошибках.""" from core.worktree import ensure_gitignore # Read-only path trick: pass a file as path (not a dir) fake_path = tmp_path / "not_a_dir.txt" fake_path.write_text("content") # Must not raise — function is defensive try: ensure_gitignore(str(fake_path)) except Exception as e: pytest.fail(f"ensure_gitignore raised unexpectedly: {e}") # ───────────────────────────────────────────────────────────────────────────── # 5. runner — worktree lifecycle в run_pipeline # ───────────────────────────────────────────────────────────────────────────── class TestRunPipelineWorktrees: @patch("agents.runner.subprocess.run") @patch("core.worktree.cleanup_worktree") @patch("core.worktree.merge_worktree", return_value={"success": True, "conflicts": [], "merged_files": []}) @patch("core.worktree.create_worktree", return_value="/tmp/fake-worktree") @patch("core.worktree.ensure_gitignore") def test_worktrees_enabled_eligible_role_creates_worktree( self, mock_gitignore, mock_create, mock_merge, mock_cleanup, mock_runner_run, conn_wt_enabled ): """worktrees_enabled=True + роль debugger → create_worktree вызывается.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is True mock_create.assert_called_once() call_args = mock_create.call_args[0] assert "WT1-001" in call_args[1] assert "debugger" in call_args[2] @patch("agents.runner.subprocess.run") @patch("core.worktree.subprocess.run") def test_worktrees_disabled_no_worktree_created( self, mock_wt_run, mock_runner_run, conn ): """worktrees_enabled=False → create_worktree НЕ вызывается.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() mock_wt_run.return_value = _ok_run() with patch("core.worktree._git", return_value="git"): result = run_pipeline(conn, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is True # git worktree add must NOT have been called cmds = [c[0][0] for c in mock_wt_run.call_args_list] worktree_add_calls = [cmd for cmd in cmds if "worktree" in cmd and "add" in cmd] assert len(worktree_add_calls) == 0 @patch("agents.runner.subprocess.run") @patch("core.worktree.subprocess.run") def test_non_eligible_role_no_worktree( self, mock_wt_run, mock_runner_run, conn_wt_enabled ): """Роль tester не входит в _WORKTREE_ROLES → worktree не создаётся.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() mock_wt_run.return_value = _ok_run() with patch("core.worktree._git", return_value="git"): result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "tester", "brief": "test"}]) assert result["success"] is True cmds = [c[0][0] for c in mock_wt_run.call_args_list] worktree_add_calls = [cmd for cmd in cmds if "worktree" in cmd and "add" in cmd] assert len(worktree_add_calls) == 0 @patch("agents.runner.subprocess.run") @patch("core.worktree.create_worktree", return_value=None) @patch("core.worktree.ensure_gitignore") def test_create_worktree_returns_none_fallback_no_crash( self, mock_gitignore, mock_create, mock_runner_run, conn_wt_enabled ): """Если create_worktree возвращает None (не-git dir) — pipeline продолжается нормально.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is True assert result["steps_completed"] == 1 @patch("agents.runner.subprocess.run") @patch("core.worktree.cleanup_worktree") @patch("core.worktree.merge_worktree", return_value={"success": True, "conflicts": [], "merged_files": []}) @patch("core.worktree.create_worktree", return_value="/tmp/WT1-001-debugger") @patch("core.worktree.ensure_gitignore") def test_successful_step_calls_merge_then_cleanup( self, mock_gitignore, mock_create, mock_merge, mock_cleanup, mock_runner_run, conn_wt_enabled ): """После успешного шага: merge_worktree вызывается, затем cleanup_worktree.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is True mock_merge.assert_called_once() mock_cleanup.assert_called_once() @patch("agents.runner.subprocess.run") @patch("core.worktree.cleanup_worktree") @patch("core.worktree.merge_worktree") @patch("core.worktree.create_worktree", return_value="/tmp/WT1-001-debugger") @patch("core.worktree.ensure_gitignore") def test_failed_step_calls_cleanup_without_merge( self, mock_gitignore, mock_create, mock_merge, mock_cleanup, mock_runner_run, conn_wt_enabled ): """После неудачного шага: cleanup_worktree вызывается, merge_worktree — нет.""" from agents.runner import run_pipeline fail = MagicMock() fail.returncode = 1 fail.stdout = "" fail.stderr = "compilation error" mock_runner_run.return_value = fail result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is False mock_merge.assert_not_called() mock_cleanup.assert_called_once() @patch("agents.runner.subprocess.run") @patch("core.worktree.cleanup_worktree") @patch("core.worktree.merge_worktree", return_value={"success": False, "conflicts": ["src/app.py"], "merged_files": []}) @patch("core.worktree.create_worktree", return_value="/tmp/WT1-001-debugger") @patch("core.worktree.ensure_gitignore") def test_merge_conflict_sets_task_blocked( self, mock_gitignore, mock_create, mock_merge, mock_cleanup, mock_runner_run, conn_wt_enabled ): """При конфликте merge: задача переходит в blocked с корректной причиной.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) # pipeline fails due to merge conflict assert result["success"] is False task = models.get_task(conn_wt_enabled, "WT1-001") assert task["status"] == "blocked" assert "src/app.py" in (task.get("blocked_reason") or "") # cleanup still called after conflict mock_cleanup.assert_called_once() @patch("agents.runner.subprocess.run") @patch("core.worktree.cleanup_worktree", side_effect=Exception("disk error")) @patch("core.worktree.merge_worktree", return_value={"success": True, "conflicts": [], "merged_files": []}) @patch("core.worktree.create_worktree", return_value="/tmp/WT1-001-debugger") @patch("core.worktree.ensure_gitignore") def test_cleanup_exception_does_not_propagate( self, mock_gitignore, mock_create, mock_merge, mock_cleanup, mock_runner_run, conn_wt_enabled ): """Исключение в cleanup_worktree не блокирует pipeline — он завершается успешно.""" from agents.runner import run_pipeline mock_runner_run.return_value = _claude_ok() # Must not raise result = run_pipeline(conn_wt_enabled, "WT1-001", [{"role": "debugger", "brief": "fix"}]) assert result["success"] is True