diff --git a/src/forge/workflow/nodes/workspace_setup.py b/src/forge/workflow/nodes/workspace_setup.py index 190a5b07..5b0196c1 100644 --- a/src/forge/workflow/nodes/workspace_setup.py +++ b/src/forge/workflow/nodes/workspace_setup.py @@ -232,38 +232,46 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState: git.clone() logger.info(f"Clone completed successfully for {current_repo}") - # Detect the upstream default branch (main, master, etc.) + # Detect the upstream default branch and establish the durable fork + # before implementation starts. Every implementation/review handoff + # pushes to this fork, so continuing without it would guarantee a + # later persistence failure. default_branch = "main" + fork_owner = state.get("fork_owner", "") + fork_repo_name = state.get("fork_repo", "") if current_repo and "/" in current_repo: owner, repo_name = current_repo.split("/", 1) github = GitHubClient() try: - repo_data = await github.get_repository(owner, repo_name) - default_branch = repo_data.get("default_branch", "main") - logger.info(f"Detected default branch for {current_repo}: {default_branch}") - except Exception as exc: - logger.warning(f"Could not detect default branch for {current_repo}: {exc}") + try: + repo_data = await github.get_repository(owner, repo_name) + default_branch = repo_data.get("default_branch", "main") + logger.info(f"Detected default branch for {current_repo}: {default_branch}") + except Exception as exc: + logger.warning(f"Could not detect default branch for {current_repo}: {exc}") + + if not fork_owner or not fork_repo_name: + fork_data = await github.get_or_create_fork(owner, repo_name) + fork_owner = fork_data["owner"]["login"] + fork_repo_name = fork_data["name"] + + await github.sync_fork_with_upstream( + fork_owner, + fork_repo_name, + branch=default_branch, + ) finally: await github.close() # Set up feature branch. - # If the workflow already created a PR (fork_owner/fork_repo in state), - # the branch lives on the fork. Add the fork remote, check whether the - # branch exists there, and check it out so we don't lose history. - fork_owner = state.get("fork_owner", "") - fork_repo_name = state.get("fork_repo", "") - - if fork_owner and fork_repo_name: - git.add_fork_remote(fork_owner, fork_repo_name) - branch_exists_on_fork = git.remote_branch_exists(workspace.branch_name, remote="fork") - if branch_exists_on_fork: - logger.info( - f"Branch '{workspace.branch_name}' exists on fork " - f"{fork_owner}/{fork_repo_name} — checking it out" - ) - git.checkout_branch(workspace.branch_name, remote="fork") - else: - git.create_branch(default_branch) + git.add_fork_remote(fork_owner, fork_repo_name) + branch_exists_on_fork = git.remote_branch_exists(workspace.branch_name, remote="fork") + if branch_exists_on_fork: + logger.info( + f"Branch '{workspace.branch_name}' exists on fork " + f"{fork_owner}/{fork_repo_name} — checking it out" + ) + git.checkout_branch(workspace.branch_name, remote="fork") else: git.create_branch(default_branch) @@ -303,6 +311,8 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState: **state, "workspace_path": str(workspace.path), "current_repo": current_repo, + "fork_owner": fork_owner, + "fork_repo": fork_repo_name, "context": context, "current_node": "implementation", "last_error": None, diff --git a/tests/unit/workflow/nodes/test_workspace_setup.py b/tests/unit/workflow/nodes/test_workspace_setup.py index 3e42cd68..1ff16ba9 100644 --- a/tests/unit/workflow/nodes/test_workspace_setup.py +++ b/tests/unit/workflow/nodes/test_workspace_setup.py @@ -64,6 +64,20 @@ def create_mock_guardrails_loader(): return mock +@pytest.fixture(autouse=True) +def mock_workspace_github(): + """Keep workspace tests isolated from GitHub fork operations.""" + github = MagicMock() + github.get_repository = AsyncMock(return_value={"default_branch": "main"}) + github.get_or_create_fork = AsyncMock( + return_value={"owner": {"login": "fork-owner"}, "name": "test-repo"} + ) + github.sync_fork_with_upstream = AsyncMock(return_value=True) + github.close = AsyncMock() + with patch("forge.workflow.nodes.workspace_setup.GitHubClient", return_value=github): + yield github + + class TestWorkspaceSetupStatusComment: """Test cases for workspace setup posting status comments.""" @@ -278,6 +292,77 @@ async def test_workspace_setup_continues_on_jira_failure(self, caplog): assert result["workspace_path"] == str(Path("/tmp/test-workspace")) mock_jira.close.assert_called_once() + @pytest.mark.asyncio + async def test_workspace_setup_fails_when_fork_cannot_be_created( + self, mock_workspace_github + ): + """Implementation must not start without its durable backup remote.""" + mock_workspace_github.get_or_create_fork.side_effect = RuntimeError( + "fork creation denied" + ) + mock_jira = create_mock_jira_client() + mock_manager, _ = create_mock_workspace_manager() + mock_git = create_mock_git_operations() + + state = create_initial_feature_state( + ticket_key="TEST-FORK-FAIL", + current_repo="owner/test-repo", + ) + + with ( + patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira), + patch( + "forge.workflow.nodes.workspace_setup.get_workspace_manager", + return_value=mock_manager, + ), + patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), + ): + result = await setup_workspace(state) + + assert result["current_node"] == "setup_workspace" + assert result["last_error"] == "fork creation denied" + mock_git.add_fork_remote.assert_not_called() + mock_git.create_branch.assert_not_called() + + +class TestWorkspaceSetupForkBootstrap: + """Tests for creating and checkpointing the implementation backup fork.""" + + @pytest.mark.asyncio + async def test_creates_fork_remote_before_implementation( + self, mock_workspace_github + ): + mock_jira = create_mock_jira_client() + mock_manager, _ = create_mock_workspace_manager() + mock_git = create_mock_git_operations() + mock_guardrails_loader = create_mock_guardrails_loader() + state = create_initial_feature_state( + ticket_key="TEST-FORK", + current_repo="upstream/repo", + ) + + with ( + patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira), + patch( + "forge.workflow.nodes.workspace_setup.get_workspace_manager", + return_value=mock_manager, + ), + patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), + patch("forge.workflow.nodes.workspace_setup.GuardrailsLoader", mock_guardrails_loader), + ): + result = await setup_workspace(state) + + mock_workspace_github.get_or_create_fork.assert_awaited_once_with( + "upstream", "repo" + ) + mock_workspace_github.sync_fork_with_upstream.assert_awaited_once_with( + "fork-owner", "test-repo", branch="main" + ) + mock_git.add_fork_remote.assert_called_once_with("fork-owner", "test-repo") + assert result["fork_owner"] == "fork-owner" + assert result["fork_repo"] == "test-repo" + assert result["current_node"] == "implementation" + class TestPrepareWorkspaceRecovery: """Tests for prepare_workspace workspace sync/recreation behavior."""