From 576941f66fed4430a395f86c8d78924ae1631392 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:43:51 +0000 Subject: [PATCH 1/6] Initial plan From c9ba089da58b7e1645e5e2c9d07a6a46667fff10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:03:54 +0000 Subject: [PATCH 2/6] fix: use target-repo config to find checkout path in pushToPullRequestBranchHandler When a workflow configures `target-repo` for `push_to_pull_request_branch`, call `findRepoCheckout` to resolve the subdirectory checkout path, ensuring patch generation runs from the correct directory rather than always using GITHUB_WORKSPACE root. Closes #21307 Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b40b9f06-4916-453b-a852-db2c1a780448 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_outputs_handlers.cjs | 19 +++---- .../setup/js/safe_outputs_handlers.test.cjs | 50 ++++++++++++++++++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 02bba1a83d8..ed3b499848c 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -486,12 +486,15 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Get base branch for the resolved target repository const baseBranch = await getBaseBranch(repoParts); - // Determine the working directory for git operations - // If repo is specified, find where it's checked out + // Determine the working directory for git operations. + // Look up the checkout path when the target repo is explicitly provided by the agent + // or explicitly configured via target-repo in the workflow config — this ensures patch + // generation runs from the correct directory when the target repo is checked out in a subdirectory. let repoCwd = null; - if (entry.repo && entry.repo.trim()) { - const repoSlug = repoResult.repo; - const checkoutResult = findRepoCheckout(repoSlug); + const itemRepo = repoResult.repo; + if ((entry.repo && entry.repo.trim()) || pushConfig["target-repo"]) { + server.debug(`Looking for checkout of target repo: ${itemRepo}`); + const checkoutResult = findRepoCheckout(itemRepo); if (!checkoutResult.success) { return { content: [ @@ -499,9 +502,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { type: "text", text: JSON.stringify({ result: "error", - error: - `Repository checkout not found for ${repoSlug}. Ensure the repository is checked out in this workflow using actions/checkout. ` + - "If checking out multiple repositories, use the 'path' input so the checkout can be located.", + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. ` + "If checking out multiple repositories, use the 'path' input so the checkout can be located.", }), }, ], @@ -510,7 +511,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { } repoCwd = checkoutResult.path; entry.repo_cwd = repoCwd; - server.debug(`Selected checkout folder for ${repoSlug}: ${repoCwd}`); + server.debug(`Selected checkout folder for ${itemRepo}: ${repoCwd}`); } // If branch is not provided, is empty, or equals the base branch, use the current branch from git diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 8a055023362..9c547b5f832 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -675,11 +675,57 @@ describe("safe_outputs_handlers", () => { expect(result.isError).toBe(true); const responseData = JSON.parse(result.content[0].text); expect(responseData.result).toBe("error"); - expect(responseData.error).toContain("Repository checkout not found for test-owner/test-repo"); - expect(responseData.error).toContain("actions/checkout"); + expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); + expect(responseData.error).toContain("path in checkout"); expect(responseData.error).toContain("'path' input"); }); + it("should return error when defaultTargetRepo checkout is not found and entry.repo is not set", async () => { + const configWithTarget = { + push_to_pull_request_branch: { "target-repo": "test-owner/test-repo" }, + }; + const handlersWithTarget = createHandlers(mockServer, mockAppendSafeOutput, configWithTarget); + + const result = await handlersWithTarget.pushToPullRequestBranchHandler({ + branch: "feature/test-change", + }); + + expect(result.isError).toBe(true); + const responseData = JSON.parse(result.content[0].text); + expect(responseData.result).toBe("error"); + expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); + expect(responseData.error).toContain("path in checkout"); + }); + + it("should detect branch from defaultTargetRepo checkout when entry.repo is not provided", async () => { + const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits(); + + const configWithTarget = { + push_to_pull_request_branch: { "target-repo": "test-owner/test-repo" }, + }; + const handlersWithTarget = createHandlers(mockServer, mockAppendSafeOutput, configWithTarget); + + process.env.GITHUB_BASE_REF = "main"; + try { + const result = await handlersWithTarget.pushToPullRequestBranchHandler({ + branch: "main", + }); + + expect(result.isError).toBeFalsy(); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining(`Selected checkout folder for test-owner/test-repo: ${targetRepoDir}`)); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("detecting actual working branch: feature/test-change")); + expect(mockAppendSafeOutput).toHaveBeenCalledWith( + expect.objectContaining({ + type: "push_to_pull_request_branch", + branch: "feature/test-change", + repo_cwd: targetRepoDir, + }) + ); + } finally { + delete process.env.GITHUB_BASE_REF; + } + }); + it("should detect branch from the checked out target repo when repo is provided", async () => { const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits(); From 3dd08ad5ad73538a670aa101eadc240af5202177 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:05:18 +0000 Subject: [PATCH 3/6] refactor: use template literal for error message and add debug assertion to test Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b40b9f06-4916-453b-a852-db2c1a780448 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_outputs_handlers.cjs | 2 +- actions/setup/js/safe_outputs_handlers.test.cjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index ed3b499848c..329ff135c1e 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -502,7 +502,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { type: "text", text: JSON.stringify({ result: "error", - error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. ` + "If checking out multiple repositories, use the 'path' input so the checkout can be located.", + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. If checking out multiple repositories, use the 'path' input so the checkout can be located.`, }), }, ], diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 9c547b5f832..c7aff922310 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -712,6 +712,7 @@ describe("safe_outputs_handlers", () => { }); expect(result.isError).toBeFalsy(); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Looking for checkout of target repo: test-owner/test-repo")); expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining(`Selected checkout folder for test-owner/test-repo: ${targetRepoDir}`)); expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("detecting actual working branch: feature/test-change")); expect(mockAppendSafeOutput).toHaveBeenCalledWith( From b853ca79794c5e794f9fbd6f7bbcf207224390ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:14:58 +0000 Subject: [PATCH 4/6] fix: pass repoCwd to all git ops in push_to_pull_request_branch conclusion handler Resolves #28374. When the target repo differs from the workflow repo, find its checkout directory using findRepoCheckout and pass cwd to every git exec call (fetch, checkout, am, push, ls-remote, rev-parse, rev-list, review-branch, fallback-branch, bundle ops, and pushSignedCommits). Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3b1d2ad5-2971-4fdf-a11c-1bb150651885 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/push_to_pull_request_branch.cjs | 61 ++++++++++---- .../js/push_to_pull_request_branch.test.cjs | 83 +++++++++++++++++++ 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 41f19229181..44f39f583df 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -17,6 +17,7 @@ const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList } = require("./messages_core.cjs"); const { getGitAuthEnv } = require("./git_helpers.cjs"); +const { findRepoCheckout } = require("./find_repo_checkout.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -353,6 +354,25 @@ async function main(config = {}) { core.info(`Target repository: ${itemRepo}`); + // Resolve the checkout directory for the target repo. + // When the target repo differs from the workflow repo, it may be checked out + // into a subdirectory of GITHUB_WORKSPACE (e.g. via actions/checkout path:). + // All git operations must run from that directory, not from GITHUB_WORKSPACE. + let repoCwd = undefined; + const workflowRepo = process.env.GITHUB_REPOSITORY || ""; + if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) { + core.info(`Cross-repo push: looking for checkout of ${itemRepo}`); + const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos }); + if (!checkoutResult.success) { + return { + success: false, + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. If checking out multiple repositories, use the 'path' input so the checkout can be located.`, + }; + } + repoCwd = checkoutResult.path; + core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`); + } + // Fetch the specific PR to get its head branch, title, and labels let pullRequest; try { @@ -490,6 +510,7 @@ async function main(config = {}) { { const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, + ...(repoCwd ? { cwd: repoCwd } : {}), ignoreReturnCode: true, }); @@ -525,6 +546,7 @@ async function main(config = {}) { core.info(`Fetching branch: ${branchName}`); await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], { env: { ...process.env, ...gitAuthEnv }, + ...(repoCwd ? { cwd: repoCwd } : {}), }); } catch (fetchError) { const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError); @@ -538,7 +560,7 @@ async function main(config = {}) { // Check if branch exists on origin try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); + await exec.exec(`git rev-parse --verify origin/${branchName}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); } catch (verifyError) { const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName); if (ignoreMissingBranchFailure) { @@ -550,7 +572,7 @@ async function main(config = {}) { // Checkout the branch from origin try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); + await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info(`Checked out existing branch from origin: ${branchName}`); } catch (checkoutError) { return { success: false, error: `Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}` }; @@ -566,7 +588,7 @@ async function main(config = {}) { if (hasChanges) { // Capture HEAD before applying changes to compute new-commit count later try { - const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); remoteHeadBeforePatch = stdout.trim(); } catch { // Non-fatal - extra empty commit will be skipped @@ -579,16 +601,16 @@ async function main(config = {}) { const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`; try { // Fetch from bundle into a temporary ref - await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`]); + await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info(`Fetched bundle to ${bundleRef}`); // Fast-forward the current branch to the bundle tip - await exec.exec("git", ["merge", "--ff-only", bundleRef]); + await exec.exec("git", ["merge", "--ff-only", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Fast-forwarded branch to bundle tip"); // Clean up the temporary ref try { - await exec.exec("git", ["update-ref", "-d", bundleRef]); + await exec.exec("git", ["update-ref", "-d", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); } catch { // Non-fatal cleanup } @@ -596,7 +618,7 @@ async function main(config = {}) { core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`); // Clean up temp ref if it exists try { - await exec.exec("git", ["update-ref", "-d", bundleRef]); + await exec.exec("git", ["update-ref", "-d", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); } catch { // Ignore } @@ -631,7 +653,7 @@ async function main(config = {}) { // Use --3way to handle cross-repo patches where the patch base may differ from target repo // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source - await exec.exec(`git am --3way ${patchFilePath}`); + await exec.exec(`git am --3way ${patchFilePath}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Patch applied successfully"); } catch (error) { core.error(`Failed to apply patch: ${getErrorMessage(error)}`); @@ -640,23 +662,23 @@ async function main(config = {}) { try { core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); + const statusResult = await exec.getExecOutput("git", ["status"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Git status output:"); core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); + const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Recent commits (last 5):"); core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); + const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Uncommitted changes:"); core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); + const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Failed patch diff:"); core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); + const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info("Failed patch (full):"); core.info(patchFullResult.stdout); } catch (investigateError) { @@ -679,12 +701,13 @@ async function main(config = {}) { const reviewBranchName = normalizeBranchName(`${branchName}-review`, String(Date.now())); try { // Rename current local branch to review branch - await exec.exec("git", ["checkout", "-b", reviewBranchName]); + await exec.exec("git", ["checkout", "-b", reviewBranchName], ...(repoCwd ? [{ cwd: repoCwd }] : [])); core.info(`Created review branch: ${reviewBranchName}`); // Push the review branch await exec.exec("git", ["push", "origin", reviewBranchName], { env: { ...process.env, ...gitAuthEnv }, + ...(repoCwd ? { cwd: repoCwd } : {}), }); core.info(`Pushed review branch: ${reviewBranchName}`); @@ -750,7 +773,7 @@ async function main(config = {}) { repo: repoParts.repo, branch: branchName, baseRef: remoteHeadBeforePatch || `origin/${branchName}`, - cwd: process.cwd(), + cwd: repoCwd || process.cwd(), gitAuthEnv, }); if (pushedSha) { @@ -771,6 +794,7 @@ async function main(config = {}) { try { const lsRemoteAfterPushResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, + ...(repoCwd ? { cwd: repoCwd } : {}), ignoreReturnCode: true, }); @@ -790,9 +814,10 @@ async function main(config = {}) { const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now())); core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`); try { - await exec.exec("git", ["checkout", "-b", fallbackBranchName]); + await exec.exec("git", ["checkout", "-b", fallbackBranchName], ...(repoCwd ? [{ cwd: repoCwd }] : [])); await exec.exec("git", ["push", "origin", fallbackBranchName], { env: { ...process.env, ...gitAuthEnv }, + ...(repoCwd ? { cwd: repoCwd } : {}), }); const fallbackBody = [ @@ -842,7 +867,7 @@ async function main(config = {}) { // Count new commits pushed for the CI trigger decision if (remoteHeadBeforePatch) { try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`]); + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`], ...(repoCwd ? [{ cwd: repoCwd }] : [])); newCommitCount = parseInt(countStr.trim(), 10); core.info(`${newCommitCount} new commit(s) pushed to branch`); } catch { @@ -872,7 +897,7 @@ async function main(config = {}) { // Fall back to local HEAD only if the helper did not return one. let commitSha = pushedCommitSha; if (!commitSha) { - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); if (commitShaRes.exitCode !== 0) { return { success: false, error: "Failed to get commit SHA" }; } diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 4d5ea8fa604..369c304a88e 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1666,6 +1666,89 @@ ${diffs} expect(result.error || "").not.toContain("outside the allowed-files list"); }); }); + + // ────────────────────────────────────────────────────── + // Cross-Repo Checkout Scenarios + // ────────────────────────────────────────────────────── + + describe("cross-repo checkout", () => { + it("should return error when target repo differs from workflow repo and is not found in workspace", async () => { + // GITHUB_REPOSITORY is set to "test-owner/test-repo" in beforeEach + // Targeting "other-owner/other-repo" - different repo, not checked out + mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } }, + }, + base: { + repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } }, + }, + title: "Cross-repo PR", + labels: [], + }, + }); + + const patchPath = createPatchFile(); + const module = await loadModule(); + const handler = await module.main({ "target-repo": "other-owner/other-repo" }); + + const result = await handler({ patch_path: patchPath, pull_request_number: 42 }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("other-owner/other-repo"); + expect(result.error).toContain("not found in workspace"); + }); + + it("should pass cwd to git commands when target repo is checked out in a subdirectory", async () => { + // Create a subdirectory checkout with a remote that matches "other-owner/other-repo" + const subRepoDir = path.join(tempDir, "other-repo"); + fs.mkdirSync(subRepoDir, { recursive: true }); + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git config user.email 'test@example.com'", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git config user.name 'Test User'", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git remote add origin https://github.com/other-owner/other-repo.git", { cwd: subRepoDir, stdio: "pipe" }); + + // Set workspace to tempDir so findRepoCheckout scans it + process.env.GITHUB_WORKSPACE = tempDir; + + mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } }, + }, + base: { + repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } }, + }, + title: "Cross-repo PR", + labels: [], + }, + }); + mockGithub.rest.repos.get = vi.fn().mockResolvedValue({ data: { default_branch: "main" } }); + mockGithub.rest.repos.getBranchProtection = vi.fn().mockRejectedValue(Object.assign(new Error("not protected"), { status: 404 })); + + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const patchPath = createPatchFile(); + const module = await loadModule(); + const handler = await module.main({ "target-repo": "other-owner/other-repo" }); + + await handler({ patch_path: patchPath, pull_request_number: 42 }, {}); + + // Verify git ls-remote was called with cwd pointing at the subdirectory + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining(`Found checkout for other-owner/other-repo at: ${subRepoDir}`)); + + // Verify at least one exec call received cwd pointing at the subdirectory + const allExecCalls = [...mockExec.exec.mock.calls, ...mockExec.getExecOutput.mock.calls]; + const cwdCalls = allExecCalls.filter(call => { + const opts = call.find(arg => arg && typeof arg === "object" && !Array.isArray(arg) && "cwd" in arg); + return opts && opts.cwd === subRepoDir; + }); + expect(cwdCalls.length).toBeGreaterThan(0); + }); + }); }); // ────────────────────────────────────────────────────── From 82ecb333eebf0ed6083896b1d1626e7873c8e4c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:18:10 +0000 Subject: [PATCH 5/6] refactor: use baseGitOpts helper for consistent cwd passing in git exec calls Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3b1d2ad5-2971-4fdf-a11c-1bb150651885 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/push_to_pull_request_branch.cjs | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 44f39f583df..5266db3f9fd 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -373,7 +373,8 @@ async function main(config = {}) { core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`); } - // Fetch the specific PR to get its head branch, title, and labels + // Base options for all git exec calls - includes cwd when running in a subdirectory checkout + const baseGitOpts = repoCwd ? { cwd: repoCwd } : {}; let pullRequest; try { const response = await githubClient.rest.pulls.get({ @@ -510,7 +511,7 @@ async function main(config = {}) { { const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, - ...(repoCwd ? { cwd: repoCwd } : {}), + ...baseGitOpts, ignoreReturnCode: true, }); @@ -546,7 +547,7 @@ async function main(config = {}) { core.info(`Fetching branch: ${branchName}`); await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], { env: { ...process.env, ...gitAuthEnv }, - ...(repoCwd ? { cwd: repoCwd } : {}), + ...baseGitOpts, }); } catch (fetchError) { const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError); @@ -560,7 +561,7 @@ async function main(config = {}) { // Check if branch exists on origin try { - await exec.exec(`git rev-parse --verify origin/${branchName}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec(`git rev-parse --verify origin/${branchName}`, [], baseGitOpts); } catch (verifyError) { const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName); if (ignoreMissingBranchFailure) { @@ -572,7 +573,7 @@ async function main(config = {}) { // Checkout the branch from origin try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], baseGitOpts); core.info(`Checked out existing branch from origin: ${branchName}`); } catch (checkoutError) { return { success: false, error: `Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}` }; @@ -588,7 +589,7 @@ async function main(config = {}) { if (hasChanges) { // Capture HEAD before applying changes to compute new-commit count later try { - const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts); remoteHeadBeforePatch = stdout.trim(); } catch { // Non-fatal - extra empty commit will be skipped @@ -601,16 +602,16 @@ async function main(config = {}) { const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`; try { // Fetch from bundle into a temporary ref - await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`], baseGitOpts); core.info(`Fetched bundle to ${bundleRef}`); // Fast-forward the current branch to the bundle tip - await exec.exec("git", ["merge", "--ff-only", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["merge", "--ff-only", bundleRef], baseGitOpts); core.info("Fast-forwarded branch to bundle tip"); // Clean up the temporary ref try { - await exec.exec("git", ["update-ref", "-d", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts); } catch { // Non-fatal cleanup } @@ -618,7 +619,7 @@ async function main(config = {}) { core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`); // Clean up temp ref if it exists try { - await exec.exec("git", ["update-ref", "-d", bundleRef], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts); } catch { // Ignore } @@ -653,7 +654,7 @@ async function main(config = {}) { // Use --3way to handle cross-repo patches where the patch base may differ from target repo // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source - await exec.exec(`git am --3way ${patchFilePath}`, [], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec(`git am --3way ${patchFilePath}`, [], baseGitOpts); core.info("Patch applied successfully"); } catch (error) { core.error(`Failed to apply patch: ${getErrorMessage(error)}`); @@ -662,23 +663,23 @@ async function main(config = {}) { try { core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const statusResult = await exec.getExecOutput("git", ["status"], baseGitOpts); core.info("Git status output:"); core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"], baseGitOpts); core.info("Recent commits (last 5):"); core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"], baseGitOpts); core.info("Uncommitted changes:"); core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"], baseGitOpts); core.info("Failed patch diff:"); core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"], baseGitOpts); core.info("Failed patch (full):"); core.info(patchFullResult.stdout); } catch (investigateError) { @@ -701,13 +702,13 @@ async function main(config = {}) { const reviewBranchName = normalizeBranchName(`${branchName}-review`, String(Date.now())); try { // Rename current local branch to review branch - await exec.exec("git", ["checkout", "-b", reviewBranchName], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["checkout", "-b", reviewBranchName], baseGitOpts); core.info(`Created review branch: ${reviewBranchName}`); // Push the review branch await exec.exec("git", ["push", "origin", reviewBranchName], { env: { ...process.env, ...gitAuthEnv }, - ...(repoCwd ? { cwd: repoCwd } : {}), + ...baseGitOpts, }); core.info(`Pushed review branch: ${reviewBranchName}`); @@ -773,7 +774,7 @@ async function main(config = {}) { repo: repoParts.repo, branch: branchName, baseRef: remoteHeadBeforePatch || `origin/${branchName}`, - cwd: repoCwd || process.cwd(), + ...baseGitOpts, gitAuthEnv, }); if (pushedSha) { @@ -794,7 +795,7 @@ async function main(config = {}) { try { const lsRemoteAfterPushResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, - ...(repoCwd ? { cwd: repoCwd } : {}), + ...baseGitOpts, ignoreReturnCode: true, }); @@ -814,10 +815,10 @@ async function main(config = {}) { const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now())); core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`); try { - await exec.exec("git", ["checkout", "-b", fallbackBranchName], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + await exec.exec("git", ["checkout", "-b", fallbackBranchName], baseGitOpts); await exec.exec("git", ["push", "origin", fallbackBranchName], { env: { ...process.env, ...gitAuthEnv }, - ...(repoCwd ? { cwd: repoCwd } : {}), + ...baseGitOpts, }); const fallbackBody = [ @@ -867,7 +868,7 @@ async function main(config = {}) { // Count new commits pushed for the CI trigger decision if (remoteHeadBeforePatch) { try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`], baseGitOpts); newCommitCount = parseInt(countStr.trim(), 10); core.info(`${newCommitCount} new commit(s) pushed to branch`); } catch { @@ -897,7 +898,7 @@ async function main(config = {}) { // Fall back to local HEAD only if the helper did not return one. let commitSha = pushedCommitSha; if (!commitSha) { - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"], ...(repoCwd ? [{ cwd: repoCwd }] : [])); + const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts); if (commitShaRes.exitCode !== 0) { return { success: false, error: "Failed to get commit SHA" }; } From 47c722f40621c58fc3fa04976813b4a02cf0f3c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:15:45 +0000 Subject: [PATCH 6/6] fix: address review comments - improve error messages and test name clarity Agent-Logs-Url: https://github.com/github/gh-aw/sessions/af16cbe6-1871-4390-9b68-5737f98974e0 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_to_pull_request_branch.cjs | 2 +- actions/setup/js/safe_outputs_handlers.cjs | 2 +- actions/setup/js/safe_outputs_handlers.test.cjs | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 5266db3f9fd..91b2550238f 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -366,7 +366,7 @@ async function main(config = {}) { if (!checkoutResult.success) { return { success: false, - error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. If checking out multiple repositories, use the 'path' input so the checkout can be located.`, + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, }; } repoCwd = checkoutResult.path; diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 329ff135c1e..897c676d15d 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -502,7 +502,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { type: "text", text: JSON.stringify({ result: "error", - error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with a path in checkout. If checking out multiple repositories, use the 'path' input so the checkout can be located.`, + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, }), }, ], diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index c7aff922310..09332830b67 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -676,11 +676,11 @@ describe("safe_outputs_handlers", () => { const responseData = JSON.parse(result.content[0].text); expect(responseData.result).toBe("error"); expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); - expect(responseData.error).toContain("path in checkout"); + expect(responseData.error).toContain("actions/checkout"); expect(responseData.error).toContain("'path' input"); }); - it("should return error when defaultTargetRepo checkout is not found and entry.repo is not set", async () => { + it("should return error when configured target-repo checkout is not found and entry.repo is not set", async () => { const configWithTarget = { push_to_pull_request_branch: { "target-repo": "test-owner/test-repo" }, }; @@ -694,7 +694,8 @@ describe("safe_outputs_handlers", () => { const responseData = JSON.parse(result.content[0].text); expect(responseData.result).toBe("error"); expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); - expect(responseData.error).toContain("path in checkout"); + expect(responseData.error).toContain("actions/checkout"); + expect(responseData.error).toContain("'path' input"); }); it("should detect branch from defaultTargetRepo checkout when entry.repo is not provided", async () => {