From 1963cfb2e50c69a12a4b513678de1b18a7f790a2 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Thu, 18 Jun 2026 16:07:20 +0100 Subject: [PATCH 1/2] fix: stage cross-repo subdirectory sample patches in the right checkout (#40086) apply_samples.cjs always created the sample branch and committed the patch in the main workspace root. For a cross-repo checkout placed in a subdirectory (path: github), the safe-outputs MCP handler resolves the target repo to the subdirectory via the checkout manifest and looks for the branch there, so it failed with 'fatal: Needed a single revision'. Resolve the patch-staging directory using the same manifest-first findRepoCheckout the MCP handler uses, driven by the sample's arguments.repo override or the configured target-repo. Falls back to the workspace root when no target repo is set or the checkout cannot be located. --- ...apply-samples-cross-repo-subdir-staging.md | 5 + actions/setup/js/apply_samples.cjs | 96 +++++++++++++++++-- actions/setup/js/apply_samples.test.cjs | 73 ++++++++++++++ 3 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 .changeset/patch-apply-samples-cross-repo-subdir-staging.md diff --git a/.changeset/patch-apply-samples-cross-repo-subdir-staging.md b/.changeset/patch-apply-samples-cross-repo-subdir-staging.md new file mode 100644 index 00000000000..d055a501321 --- /dev/null +++ b/.changeset/patch-apply-samples-cross-repo-subdir-staging.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix `gh aw compile --use-samples` replay failing with `Failed to pin branch '...': ERR_SYSTEM: fatal: Needed a single revision` when a `create_pull_request` / `push_to_pull_request_branch` sample patch targets a cross-repo checkout placed in a subdirectory (`path: github`). `apply_samples.cjs` now stages the sample branch and commit inside the target repo's checkout subdirectory — resolved via the same checkout manifest the safe-outputs MCP handler uses — instead of always staging in the main workspace root, so the MCP server can find and pin the branch. diff --git a/actions/setup/js/apply_samples.cjs b/actions/setup/js/apply_samples.cjs index 9089d09e4ba..f69e3a49ed9 100644 --- a/actions/setup/js/apply_samples.cjs +++ b/actions/setup/js/apply_samples.cjs @@ -23,6 +23,8 @@ // GH_AW_SAMPLES — JSON array of replay entries (required) // GH_AW_AGENT_STDIO_LOG — path where the synthetic stdio log is written // GH_AW_SAFE_OUTPUTS_CONFIG_PATH — path to the MCP server's config.json +// (also read to resolve the per-tool target-repo so +// cross-repo patches are staged in the right checkout) // GH_AW_SAFE_OUTPUTS — path to the MCP server's outputs.jsonl // GITHUB_WORKSPACE — git working directory for pre-staging (optional; // falls back to cwd) @@ -35,6 +37,7 @@ const path = require("path"); const os = require("os"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_VALIDATION, ERR_PARSE, ERR_SYSTEM, ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); +const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const DEFAULT_BASE_BRANCH = process.env.GH_AW_CUSTOM_BASE_BRANCH || process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME || "main"; const PATCH_SIDECAR_TOOLS = new Set(["create_pull_request", "push_to_pull_request_branch"]); @@ -243,6 +246,76 @@ async function derivePrHeadRef(entry) { return null; } +/** + * Read the configured `target-repo` for a given safe-output tool from the + * safe-outputs config file (GH_AW_SAFE_OUTPUTS_CONFIG_PATH). Returns an empty + * string when no config is available or no target-repo is configured. + * @param {string} tool + * @returns {string} + */ +function readConfiguredTargetRepo(tool) { + const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH; + if (!configPath || !configPath.trim()) { + return ""; + } + try { + const raw = fs.readFileSync(configPath, "utf8"); + const config = JSON.parse(raw); + const toolConfig = config && typeof config === "object" ? config[tool] : null; + if (toolConfig && typeof toolConfig === "object" && typeof toolConfig["target-repo"] === "string") { + return toolConfig["target-repo"].trim(); + } + } catch (err) { + core.debug(`apply_samples: could not read target-repo from ${configPath}: ${getErrorMessage(err)}`); + } + return ""; +} + +/** + * Resolve the on-disk working directory in which a sample's patch should be + * staged (branch created + patch committed). + * + * For cross-repo checkouts placed in a subdirectory (e.g. `path: github`), the + * branch and commit must be created inside that subdirectory so the safe-outputs + * MCP handler — which resolves the same checkout via the checkout manifest — can + * find the branch. Staging in the main workspace root instead leaves the branch + * invisible to the MCP server, producing "fatal: Needed a single revision" when + * it tries to pin the branch (issue #40086). + * + * Resolution mirrors the MCP handler: the target repo comes from the sample's + * `arguments.repo` override or the configured `target-repo`, and the checkout + * directory is resolved via the manifest-first `findRepoCheckout`. Falls back to + * the workspace root when no target repo is set or the checkout cannot be located. + * + * @param {SampleEntry} entry + * @param {string} workspace + * @returns {string} + */ +function resolvePatchWorkspace(entry, workspace) { + let targetRepo = ""; + if (entry.arguments && typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) { + targetRepo = entry.arguments.repo.trim(); + } else { + targetRepo = readConfiguredTargetRepo(entry.tool); + } + if (!targetRepo) { + return workspace; + } + try { + const result = findRepoCheckout(targetRepo, workspace); + if (result && result.success && result.path) { + if (path.resolve(result.path) !== path.resolve(workspace)) { + core.info(`apply_samples: staging patch for ${targetRepo} in checkout subdirectory ${result.path}`); + } + return result.path; + } + core.debug(`apply_samples: findRepoCheckout(${targetRepo}) did not locate a checkout; using workspace root`); + } catch (err) { + core.debug(`apply_samples: findRepoCheckout(${targetRepo}) failed: ${getErrorMessage(err)}; using workspace root`); + } + return workspace; +} + /** * Pre-stage a branch + patch for samples whose tool reads the workspace diff. * @@ -264,6 +337,11 @@ async function preStagePatch(entry, index, workspace) { return; } + // Resolve the directory in which to create the branch and commit the patch. + // For cross-repo checkouts in a subdirectory this is the checkout subdir, not + // the main workspace root (issue #40086). + const repoCwd = resolvePatchWorkspace(entry, workspace); + let branch; if (entry.tool === "push_to_pull_request_branch") { // Source ref MUST match the PR's head ref so that @@ -286,36 +364,36 @@ async function preStagePatch(entry, index, workspace) { entry.arguments.branch = branch; } - ensureGitIdentity(workspace); + ensureGitIdentity(repoCwd); // Start from the base branch so the diff is meaningful. Tolerate the case // where the base ref doesn't exist locally — fall back to HEAD. try { - runGit(["checkout", DEFAULT_BASE_BRANCH], workspace); + runGit(["checkout", DEFAULT_BASE_BRANCH], repoCwd); } catch (err) { core.warning(`apply_samples: could not check out base branch ${DEFAULT_BASE_BRANCH}: ${getErrorMessage(err)}; staying on current HEAD`); } // Create the branch (or check it out if it already exists from a previous sample). try { - runGit(["checkout", "-b", branch], workspace); + runGit(["checkout", "-b", branch], repoCwd); } catch { - runGit(["checkout", branch], workspace); + runGit(["checkout", branch], repoCwd); } // Write patch to a temp file and apply it. const tmpPatch = path.join(os.tmpdir(), `gh-aw-sample-${index + 1}.patch`); fs.writeFileSync(tmpPatch, patch.endsWith("\n") ? patch : patch + "\n"); try { - runGit(["apply", "--whitespace=nowarn", tmpPatch], workspace); + runGit(["apply", "--whitespace=nowarn", tmpPatch], repoCwd); } catch (err) { // Fall back to --3way for patches that don't apply cleanly on top of an // empty working tree (uncommon but possible for synthetic samples). - runGit(["apply", "--3way", "--whitespace=nowarn", tmpPatch], workspace); + runGit(["apply", "--3way", "--whitespace=nowarn", tmpPatch], repoCwd); } - runGit(["add", "-A"], workspace); - runGit(["commit", "-m", `gh-aw sample ${index + 1}: ${entry.tool}`, "--allow-empty"], workspace); + runGit(["add", "-A"], repoCwd); + runGit(["commit", "-m", `gh-aw sample ${index + 1}: ${entry.tool}`, "--allow-empty"], repoCwd); } /** @@ -561,6 +639,8 @@ module.exports = { main, loadSamples, preStagePatch, + resolvePatchWorkspace, + readConfiguredTargetRepo, resolveMcpServerPath, selectTokenForRepo, sendJsonRpc, diff --git a/actions/setup/js/apply_samples.test.cjs b/actions/setup/js/apply_samples.test.cjs index b5a1e3ef738..aa02151bd74 100644 --- a/actions/setup/js/apply_samples.test.cjs +++ b/actions/setup/js/apply_samples.test.cjs @@ -326,6 +326,79 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re expect(diff).toContain("+hello from a deterministic sample"); }); + it("stages the patch in the cross-repo checkout subdirectory (path: github) (issue #40086)", async () => { + // Reproduce the failing layout: a main repo at the workspace root and a + // side-repo checked out into a `github/` subdirectory. The checkout manifest + // maps the target repo to path "github", so preStagePatch must create the + // branch + commit inside ${workspace}/github — not the main repo root — + // otherwise the safe-outputs MCP server cannot pin the branch. + const workspace = makeTempDir("gh-aw-prestage-subdir-"); + initRepo(workspace, "main"); + + const subdir = path.join(workspace, "github"); + fs.mkdirSync(subdir); + initRepo(subdir, "main"); + + const manifestPath = path.join(workspace, "checkout-manifest.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + "githubnext/gh-aw-side-repo": { + repository: "githubnext/gh-aw-side-repo", + path: "github", + default_branch: "main", + }, + }) + ); + + const configPath = path.join(workspace, "config.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + create_pull_request: { "target-repo": "githubnext/gh-aw-side-repo" }, + }) + ); + + const branchName = "gh-aw-sample-copilot-siderepo-subdir-pr"; + const fileToAdd = "subdir-notes.md"; + const entry = { + tool: "create_pull_request", + arguments: { title: "Subdir PR", body: "body", branch: branchName }, + sidecars: { patch: newFileDiff(fileToAdd, "side repo content\n") }, + }; + + // Reset the checkout-manifest module cache so our manifest is loaded fresh. + require("./checkout_manifest.cjs")._resetCache(); + + const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH; + const prevManifest = process.env.GH_AW_CHECKOUT_MANIFEST; + const prevConfig = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH; + process.env.GH_AW_CUSTOM_BASE_BRANCH = "main"; + process.env.GH_AW_CHECKOUT_MANIFEST = manifestPath; + process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = configPath; + try { + await preStagePatch(entry, 0, workspace); + } finally { + require("./checkout_manifest.cjs")._resetCache(); + if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH; + else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase; + if (prevManifest === undefined) delete process.env.GH_AW_CHECKOUT_MANIFEST; + else process.env.GH_AW_CHECKOUT_MANIFEST = prevManifest; + if (prevConfig === undefined) delete process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH; + else process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH = prevConfig; + } + + // The branch + commit + file land in the side-repo subdirectory... + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], subdir).trim()).toBe(branchName); + expect(git(["branch", "--list", branchName], subdir)).toContain(branchName); + expect(fs.existsSync(path.join(subdir, fileToAdd))).toBe(true); + + // ...and NOT in the main repo root (which stays on its seed branch). + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe("main"); + expect(git(["branch", "--list", branchName], workspace).trim()).toBe(""); + expect(fs.existsSync(path.join(workspace, fileToAdd))).toBe(false); + }); + it("derives push_to_pull_request_branch branch from pull_request event payload", async () => { const workspace = makeTempDir("gh-aw-prestage-push-pr-"); initRepo(workspace, "main"); From 0bf70fb46b7aef6c0bbb26e5a22614e20a19c2d2 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Thu, 18 Jun 2026 16:19:41 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- actions/setup/js/apply_samples.cjs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/apply_samples.cjs b/actions/setup/js/apply_samples.cjs index f69e3a49ed9..f73c0ae0f05 100644 --- a/actions/setup/js/apply_samples.cjs +++ b/actions/setup/js/apply_samples.cjs @@ -258,12 +258,20 @@ function readConfiguredTargetRepo(tool) { if (!configPath || !configPath.trim()) { return ""; } + + const toolKey = typeof tool === "string" ? tool.replace(/-/g, "_") : ""; + try { const raw = fs.readFileSync(configPath, "utf8"); - const config = JSON.parse(raw); - const toolConfig = config && typeof config === "object" ? config[tool] : null; - if (toolConfig && typeof toolConfig === "object" && typeof toolConfig["target-repo"] === "string") { - return toolConfig["target-repo"].trim(); + const parsed = JSON.parse(raw); + + // Mirror safe_outputs_config.cjs behavior: normalize top-level keys by replacing '-' with '_'. + const config = parsed && typeof parsed === "object" ? Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).replace(/-/g, "_"), v])) : {}; + + const toolConfig = toolKey && config && typeof config === "object" ? config[toolKey] : null; + const target = toolConfig && typeof toolConfig === "object" ? toolConfig["target-repo"] || toolConfig["target_repo"] : null; + if (typeof target === "string") { + return target.trim(); } } catch (err) { core.debug(`apply_samples: could not read target-repo from ${configPath}: ${getErrorMessage(err)}`);