Skip to content
Merged
33 changes: 32 additions & 1 deletion actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -486,10 +486,37 @@ 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
let repoCwd = null;
if (entry.repo && entry.repo.trim()) {
const repoSlug = repoResult.repo;
const checkoutResult = findRepoCheckout(repoSlug);
if (!checkoutResult.success) {
return {
content: [
{
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.",
}),
},
],
isError: true,
};
}
repoCwd = checkoutResult.path;
entry.repo_cwd = repoCwd;
server.debug(`Selected checkout folder for ${repoSlug}: ${repoCwd}`);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot log the select folder location

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the checkout-folder debug log in 2c81766 and covered it with a targeted test assertion. Screenshot: N/A (no UI changes).

// If branch is not provided, is empty, or equals the base branch, use the current branch from git
// This handles cases where the agent incorrectly passes the base branch instead of the working branch
if (!entry.branch || entry.branch.trim() === "" || entry.branch === baseBranch) {
const detectedBranch = getCurrentBranch();
const detectedBranch = getCurrentBranch(repoCwd);

if (entry.branch === baseBranch) {
server.debug(`Branch equals base branch (${baseBranch}), detecting actual working branch: ${detectedBranch}`);
Expand All @@ -507,6 +534,10 @@ function createHandlers(server, appendSafeOutput, config = {}) {

// Build common options for both patch and bundle generation
const pushTransportOptions = { mode: "incremental" };
if (repoCwd) {
pushTransportOptions.cwd = repoCwd;
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushTransportOptions sets cwd for multi-repo, but it never sets repoSlug. Both generateGitPatch and generateGitBundle support options.repoSlug to disambiguate filenames in /tmp/gh-aw/ for multi-repo workflows; without it, two repos with the same branch name can overwrite each other’s patch/bundle files. Consider setting pushTransportOptions.repoSlug = repoResult.repo when operating in multi-repo mode (similar to createPullRequestHandler).

Suggested change
pushTransportOptions.cwd = repoCwd;
pushTransportOptions.cwd = repoCwd;
pushTransportOptions.repoSlug = repoResult.repo;

Copilot uses AI. Check for mistakes.
pushTransportOptions.repoSlug = repoResult.repo;
}
// Pass per-handler token so cross-repo PATs are used for git fetch when configured.
// Falls back to GITHUB_TOKEN if not set.
if (pushConfig["github-token"]) {
Expand Down
94 changes: 94 additions & 0 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import { createHandlers } from "./safe_outputs_handlers.cjs";

// Mock the global objects that GitHub Actions provides
Expand Down Expand Up @@ -587,6 +588,34 @@ describe("safe_outputs_handlers", () => {
});

describe("pushToPullRequestBranchHandler", () => {
function createSideRepoWithTrackedAndLocalCommits() {
const targetRepoDir = path.join(testWorkspaceDir, "target-repo");
fs.mkdirSync(targetRepoDir, { recursive: true });

execSync("git init -b main", { cwd: targetRepoDir, stdio: "pipe" });
execSync("git config user.email 'test@example.com'", { cwd: targetRepoDir, stdio: "pipe" });
execSync("git config user.name 'Test User'", { cwd: targetRepoDir, stdio: "pipe" });

fs.writeFileSync(path.join(targetRepoDir, "README.md"), "base\n");
execSync("git add README.md", { cwd: targetRepoDir, stdio: "pipe" });
execSync("git commit -m 'base commit'", { cwd: targetRepoDir, stdio: "pipe" });

execSync("git checkout -b feature/test-change", { cwd: targetRepoDir, stdio: "pipe" });
fs.writeFileSync(path.join(targetRepoDir, "README.md"), "tracked\n");
execSync("git add README.md", { cwd: targetRepoDir, stdio: "pipe" });
execSync("git commit -m 'tracked commit'", { cwd: targetRepoDir, stdio: "pipe" });
const trackedCommit = execSync("git rev-parse HEAD", { cwd: targetRepoDir, stdio: "pipe" }).toString().trim();

execSync("git remote add origin https://github.com/test-owner/test-repo.git", { cwd: targetRepoDir, stdio: "pipe" });
execSync(`git update-ref refs/remotes/origin/feature/test-change ${trackedCommit}`, { cwd: targetRepoDir, stdio: "pipe" });

fs.writeFileSync(path.join(targetRepoDir, "README.md"), "local-only\n");
execSync("git add README.md", { cwd: targetRepoDir, stdio: "pipe" });
execSync("git commit -m 'local only commit'", { cwd: targetRepoDir, stdio: "pipe" });

return { targetRepoDir };
}

it("should be defined", () => {
expect(handlers.pushToPullRequestBranchHandler).toBeDefined();
});
Expand Down Expand Up @@ -636,6 +665,71 @@ describe("safe_outputs_handlers", () => {
expect(responseData.details).toContain("git commit");
expect(responseData.details).toContain("push_to_pull_request_branch");
});

it("should return error when repo checkout is not found for explicit repo", async () => {
const result = await handlers.pushToPullRequestBranchHandler({
branch: "main",
repo: "test-owner/test-repo",
});

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("'path' input");
});

it("should detect branch from the checked out target repo when repo is provided", async () => {
const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits();

process.env.GITHUB_BASE_REF = "main";
try {
const result = await handlers.pushToPullRequestBranchHandler({
branch: "main",
repo: "test-owner/test-repo",
});

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",
})
);
} finally {
delete process.env.GITHUB_BASE_REF;
}
});

it("should include repo slug in incremental patch filename for side-repo checkout", async () => {
const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits();

process.env.GITHUB_BASE_REF = "main";
try {
const result = await handlers.pushToPullRequestBranchHandler({
branch: "feature/test-change",
repo: "test-owner/test-repo",
});

expect(result.isError).toBeFalsy();
const responseData = JSON.parse(result.content[0].text);
expect(responseData.result).toBe("success");
expect(path.basename(responseData.patch.path)).toBe("aw-test-owner-test-repo-feature-test-change.patch");

expect(mockAppendSafeOutput).toHaveBeenCalledWith(
expect.objectContaining({
type: "push_to_pull_request_branch",
repo_cwd: targetRepoDir,
patch_path: expect.stringContaining("aw-test-owner-test-repo-feature-test-change.patch"),
})
);
} finally {
delete process.env.GITHUB_BASE_REF;
}
});
});

describe("handler structure", () => {
Expand Down