From 53da1bc3d9e30091053a34c742a83e1ea25b9824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:26:22 +0000 Subject: [PATCH 1/3] Initial plan From 2751cd411777d16291aab7d80cb8027776e12d75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:35:52 +0000 Subject: [PATCH 2/3] fix: deepen synthetic PR merge-base refs Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/ado-script.md | 13 ++-- docs/execution-context.md | 8 ++- .../ado-script/src/exec-context-pr/index.ts | 2 +- .../src/shared/__tests__/merge-base.test.ts | 72 ++++++++++++++++--- .../src/shared/__tests__/prompt.test.ts | 2 + .../src/shared/__tests__/validate.test.ts | 25 ++++++- scripts/ado-script/src/shared/merge-base.ts | 54 ++++++++++++-- scripts/ado-script/src/shared/validate.ts | 22 ++++-- scripts/ado-script/test/smoke.test.ts | 1 + .../src/content/docs/reference/ado-script.mdx | 4 +- .../docs/reference/execution-context.mdx | 9 +-- src/compile/extensions/exec_context/pr.rs | 4 ++ tests/compiler_tests.rs | 17 +++-- 13 files changed, 192 insertions(+), 41 deletions(-) diff --git a/docs/ado-script.md b/docs/ado-script.md index 953f5ea5..237eed9a 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -189,7 +189,8 @@ It performs the work that used to live as ~190 lines of bash heredoc inside `src/compile/extensions/exec_context/pr.rs`: 1. **Validate identifiers** — `PR_ID`, `SYSTEM_TEAMPROJECT`, - `BUILD_REPOSITORY_NAME`, and `SYSTEM_PULLREQUEST_TARGETBRANCH` are + `BUILD_REPOSITORY_NAME`, `SYSTEM_PULLREQUEST_TARGETBRANCH`, and + `SYSTEM_PULLREQUEST_SOURCEBRANCH` are each matched against a strict allowlist regex (`validate.ts`) before any of them are interpolated into a git refspec or the agent prompt. On any failure the program writes @@ -199,10 +200,12 @@ inside `src/compile/extensions/exec_context/pr.rs`: 2. **Resolve merge-base** — if the checkout is a synthetic merge-commit (parent count ≥ 3 per ADO's PR-validation flow), `merge-base.ts::resolveMergeBase` computes `git merge-base` over - the two parents. Otherwise it fetches the target branch with - progressive deepening (`--depth=200/500/2000/--unshallow`) and - then `git merge-base` against `HEAD`. Same `BASE_SHA` semantics - in both paths (git's true common ancestor). + the two parents. If that cannot resolve in a shallow checkout, it + fetches both target and source refs with progressive deepening + (`--depth=200/500/2000/--unshallow`) and retries the parent + merge-base. Otherwise it fetches the target branch with progressive + deepening and then runs `git merge-base` against `HEAD`. Same + `BASE_SHA` semantics in both paths (git's true common ancestor). 3. **Stage artefacts** — writes `aw-context/pr/base.sha` and `aw-context/pr/head.sha` so the agent can `git diff $(cat .../base.sha)..$(cat .../head.sha)` itself. diff --git a/docs/execution-context.md b/docs/execution-context.md index 372dc0c3..5ab1b729 100644 --- a/docs/execution-context.md +++ b/docs/execution-context.md @@ -474,14 +474,18 @@ under `scripts/ado-script/src/exec-context-pr/`. The bundle: discovery — ADO already populates these. 2. **Validates identifiers** with strict allowlist regexes (`PR_ID` ⊆ digits, `PROJECT`/`REPO` ⊆ alphanumeric + `._-`, - `PROJECT` additionally allows space, `PR_TARGET_BRANCH` ⊆ + `PROJECT` additionally allows space, `PR_TARGET_BRANCH` and + `PR_SOURCE_BRANCH` ⊆ alphanumeric + `._/-`). See `validate.ts`. Failure writes `error.txt` and appends the failure prompt fragment. 3. **Detects merge-commit shape.** If `HEAD` has ≥ 3 tokens in `git rev-list --parents HEAD` (the synthetic merge commit ADO checks out for PR builds), uses `HEAD^2` as the PR head and computes `git merge-base HEAD^1 HEAD^2` as the base — same - semantics as the deepening path, no target-branch fetch needed. + semantics as the deepening path. If a shallow checkout lacks + sufficient ancestry, it fetches both the target and source refs + with progressive deepening and retries; unresolved ancestry fails + closed into `error.txt`. Otherwise: 4. **Fetches the PR target branch with progressive deepening** — `--depth=200`, then `500`, then `2000`, then finally `--unshallow`. diff --git a/scripts/ado-script/src/exec-context-pr/index.ts b/scripts/ado-script/src/exec-context-pr/index.ts index d98b3c1d..c5bf8bf7 100644 --- a/scripts/ado-script/src/exec-context-pr/index.ts +++ b/scripts/ado-script/src/exec-context-pr/index.ts @@ -122,7 +122,7 @@ export function main(env: NodeJS.ProcessEnv = process.env): number { const ids = idsOrErr; const fetchEnv = bearerEnv(env.SYSTEM_ACCESSTOKEN); - const mb = resolveMergeBase(ids.targetShort, fetchEnv); + const mb = resolveMergeBase(ids.targetShort, fetchEnv, undefined, ids.sourceShort); if (!mb.ok) { writeFailure(prDir, promptPath, mb.reason, { prId: ids.prId, project: ids.project, repo: ids.repo }); return 0; diff --git a/scripts/ado-script/src/shared/__tests__/merge-base.test.ts b/scripts/ado-script/src/shared/__tests__/merge-base.test.ts index b121dfc7..4242188a 100644 --- a/scripts/ado-script/src/shared/__tests__/merge-base.test.ts +++ b/scripts/ado-script/src/shared/__tests__/merge-base.test.ts @@ -58,13 +58,63 @@ describe("resolveMergeBase", () => { } }); - it("falls back to HEAD^1 when synthetic-merge merge-base cannot resolve", () => { - const runGit = makeRunGit([ - { - match: (a) => a.join(" ") === "rev-list --parents -n 1 HEAD", - result: { stdout: `${SHA_C} ${SHA_A} ${SHA_B}\n`, stderr: "", status: 0 }, - }, + it("deepens target and source refs when synthetic-merge merge-base cannot initially resolve", () => { + const depthArgsSeen: string[] = []; + const refsSeen: string[] = []; + const bearer = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraheader", + GIT_CONFIG_VALUE_0: "Authorization: bearer test-token", + }; + const runGit: GitRunners["runGit"] = (args, env) => { + if (args.join(" ") === "rev-list --parents -n 1 HEAD") { + return { stdout: `${SHA_C} ${SHA_A} ${SHA_B}\n`, stderr: "", status: 0 }; + } + if (args[0] === "fetch") { + expect(env).toEqual(bearer); + depthArgsSeen.push(args[2] ?? ""); + refsSeen.push(args[4] ?? ""); + return { stdout: "", stderr: "", status: 0 }; + } + return { stdout: "", stderr: "no handler", status: 1 }; + }; + let mergeBaseCalls = 0; + const gitOk: GitRunners["gitOk"] = (args) => { + if (args.join(" ") === "rev-parse HEAD") return SHA_C; + if (args.join(" ") === "rev-parse HEAD^1") return SHA_A; + if (args.join(" ") === "rev-parse HEAD^2") return SHA_B; + if (args.join(" ") === `merge-base ${SHA_A} ${SHA_B}`) { + mergeBaseCalls++; + return mergeBaseCalls < 2 ? null : SHA_M; + } + return null; + }; + + const result = resolveMergeBase("main", bearer, { runGit, gitOk }, "feature/x"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.baseSha).toBe(SHA_M); + expect(result.headSha).toBe(SHA_B); + } + expect(depthArgsSeen).toEqual(["--depth=200", "--depth=200"]); + expect(refsSeen).toEqual([ + "+refs/heads/main:refs/remotes/origin/main", + "+refs/heads/feature/x:refs/remotes/origin/feature/x", ]); + }); + + it("fails closed when synthetic-merge merge-base cannot resolve after deepening", () => { + let fetchCount = 0; + const runGit: GitRunners["runGit"] = (args) => { + if (args.join(" ") === "rev-list --parents -n 1 HEAD") { + return { stdout: `${SHA_C} ${SHA_A} ${SHA_B}\n`, stderr: "", status: 0 }; + } + if (args[0] === "fetch") { + fetchCount++; + return { stdout: "", stderr: "", status: 0 }; + } + return { stdout: "", stderr: "no handler", status: 1 }; + }; const gitOk = makeGitOk([ { match: (a) => a.join(" ") === "rev-parse HEAD", out: SHA_C }, { match: (a) => a.join(" ") === "rev-parse HEAD^1", out: SHA_A }, @@ -72,12 +122,12 @@ describe("resolveMergeBase", () => { { match: (a) => a.join(" ") === `merge-base ${SHA_A} ${SHA_B}`, out: null }, ]); - const result = resolveMergeBase("main", {}, { runGit, gitOk }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.baseSha).toBe(SHA_A); - expect(result.headSha).toBe(SHA_B); + const result = resolveMergeBase("main", {}, { runGit, gitOk }, "feature/x"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("Could not resolve base/head SHAs"); } + expect(fetchCount).toBe(8); }); it("uses progressive deepening when HEAD has only 1 parent and stops on first resolution", () => { diff --git a/scripts/ado-script/src/shared/__tests__/prompt.test.ts b/scripts/ado-script/src/shared/__tests__/prompt.test.ts index a35266fd..d7c95859 100644 --- a/scripts/ado-script/src/shared/__tests__/prompt.test.ts +++ b/scripts/ado-script/src/shared/__tests__/prompt.test.ts @@ -8,7 +8,9 @@ const ids: Identifiers = { project: "MyProject", repo: "my-repo", targetBranch: "refs/heads/main", + sourceBranch: "refs/heads/feature/x", targetShort: "main", + sourceShort: "feature/x", }; describe("successFragment", () => { diff --git a/scripts/ado-script/src/shared/__tests__/validate.test.ts b/scripts/ado-script/src/shared/__tests__/validate.test.ts index 42e36c3a..05c9d17e 100644 --- a/scripts/ado-script/src/shared/__tests__/validate.test.ts +++ b/scripts/ado-script/src/shared/__tests__/validate.test.ts @@ -6,6 +6,7 @@ function env(overrides: Record = {}): NodeJS.ProcessEnv { return { SYSTEM_PULLREQUEST_PULLREQUESTID: "4242", SYSTEM_PULLREQUEST_TARGETBRANCH: "refs/heads/main", + SYSTEM_PULLREQUEST_SOURCEBRANCH: "refs/heads/feature/x", SYSTEM_TEAMPROJECT: "MyProject", BUILD_REPOSITORY_NAME: "my-repo", ...overrides, @@ -21,7 +22,9 @@ describe("validateIdentifiers", () => { expect(result.project).toBe("MyProject"); expect(result.repo).toBe("my-repo"); expect(result.targetBranch).toBe("refs/heads/main"); + expect(result.sourceBranch).toBe("refs/heads/feature/x"); expect(result.targetShort).toBe("main"); + expect(result.sourceShort).toBe("feature/x"); } }); @@ -75,6 +78,22 @@ describe("validateIdentifiers", () => { } }); + it("rejects empty source branch with a dedicated message", () => { + const result = validateIdentifiers(env({ SYSTEM_PULLREQUEST_SOURCEBRANCH: "" })); + expect(isIdentifierError(result)).toBe(true); + if (isIdentifierError(result)) { + expect(result.reason).toContain("SourceBranch is empty"); + } + }); + + it("rejects source branch with disallowed characters", () => { + const result = validateIdentifiers(env({ SYSTEM_PULLREQUEST_SOURCEBRANCH: "refs/heads/feature; rm -rf /" })); + expect(isIdentifierError(result)).toBe(true); + if (isIdentifierError(result)) { + expect(result.reason).toContain("PR_SOURCE_BRANCH="); + } + }); + it("accepts branch names with slashes, dots, dashes, underscores", () => { const result = validateIdentifiers(env({ SYSTEM_PULLREQUEST_TARGETBRANCH: "refs/heads/release/v1.2.3-beta_rc" })); expect(isIdentifierError(result)).toBe(false); @@ -84,10 +103,14 @@ describe("validateIdentifiers", () => { }); it("handles non-refs/heads/-prefixed branch as a bare name", () => { - const result = validateIdentifiers(env({ SYSTEM_PULLREQUEST_TARGETBRANCH: "main" })); + const result = validateIdentifiers(env({ + SYSTEM_PULLREQUEST_TARGETBRANCH: "main", + SYSTEM_PULLREQUEST_SOURCEBRANCH: "feature/x", + })); expect(isIdentifierError(result)).toBe(false); if (!isIdentifierError(result)) { expect(result.targetShort).toBe("main"); + expect(result.sourceShort).toBe("feature/x"); } }); diff --git a/scripts/ado-script/src/shared/merge-base.ts b/scripts/ado-script/src/shared/merge-base.ts index e2b1593f..21b28b00 100644 --- a/scripts/ado-script/src/shared/merge-base.ts +++ b/scripts/ado-script/src/shared/merge-base.ts @@ -55,9 +55,9 @@ function countParentTokens(runners: GitRunners): number { * `depthArg` is one of `--depth=N` or `--unshallow` — passed * verbatim so the caller can iterate the progressive-deepening loop. */ -function fetchTargetAtDepth( +function fetchBranchAtDepth( runners: GitRunners, - targetShort: string, + branchShort: string, depthArg: string, env: Record, ): boolean { @@ -67,7 +67,7 @@ function fetchTargetAtDepth( "--no-tags", depthArg, "origin", - `+refs/heads/${targetShort}:refs/remotes/origin/${targetShort}`, + `+refs/heads/${branchShort}:refs/remotes/origin/${branchShort}`, ], env, ); @@ -105,7 +105,7 @@ export function ensureTargetRefFetched( // against the deepened target ref. const depths = ["--depth=200", "--depth=500", "--depth=2000", "--unshallow"]; for (const depthArg of depths) { - if (!fetchTargetAtDepth(runners, targetShort, depthArg, env)) { + if (!fetchBranchAtDepth(runners, targetShort, depthArg, env)) { // Fetch failed at this depth (e.g. --unshallow on an already- // unshallowed repo). Continue to the next depth or bail out after // the loop. @@ -122,6 +122,32 @@ export function ensureTargetRefFetched( }; } +function ensureSyntheticMergeParentsFetched( + targetShort: string, + sourceShort: string, + targetParentSha: string, + sourceParentSha: string, + env: Record, + runners: GitRunners, +): FetchDeepenResult { + const depths = ["--depth=200", "--depth=500", "--depth=2000", "--unshallow"]; + for (const depthArg of depths) { + const targetFetched = fetchBranchAtDepth(runners, targetShort, depthArg, env); + const sourceFetched = fetchBranchAtDepth(runners, sourceShort, depthArg, env); + if (!targetFetched && !sourceFetched) { + continue; + } + const mb = runners.gitOk(["merge-base", targetParentSha, sourceParentSha]) ?? ""; + if (mb.length > 0) { + return { ok: true, baseSha: mb }; + } + } + return { + ok: false, + reason: `Could not resolve merge-base between synthetic PR parents after progressive deepening of 'origin/${targetShort}' and 'origin/${sourceShort}'.`, + }; +} + /** * Resolve `BASE_SHA` and `HEAD_SHA` for the PR. * @@ -132,7 +158,8 @@ export function ensureTargetRefFetched( * default checkout mode for PR builds), `HEAD^1` is the target tip * at PR preparation time and `HEAD^2` is the PR head. We compute * `merge-base HEAD^1 HEAD^2` to match the deepening path's - * semantics. Falls back to `HEAD^1` if `merge-base` cannot resolve. + * semantics. If the shallow checkout lacks enough ancestry, fetch + * the target and source refs with progressive deepening and retry. * * 2. **Progressive deepening**: when HEAD is a normal commit, fetch * the target branch with `--depth=200`, `500`, `2000`, `--unshallow` @@ -145,6 +172,7 @@ export function resolveMergeBase( targetShort: string, env: Record, runners: GitRunners = defaultRunners, + sourceShort = "", ): MergeBaseResult { const headSha = runners.gitOk(["rev-parse", "HEAD"]) ?? ""; const parentTokens = countParentTokens(runners); @@ -159,7 +187,21 @@ export function resolveMergeBase( headTipSha = p2; if (p1.length > 0 && p2.length > 0) { const mergeBase = runners.gitOk(["merge-base", p1, p2]) ?? ""; - baseSha = mergeBase.length > 0 ? mergeBase : p1; + if (mergeBase.length > 0) { + baseSha = mergeBase; + } else if (sourceShort.length > 0) { + const fetched = ensureSyntheticMergeParentsFetched( + targetShort, + sourceShort, + p1, + p2, + env, + runners, + ); + if (fetched.ok) { + baseSha = fetched.baseSha; + } + } } } else { headTipSha = headSha; diff --git a/scripts/ado-script/src/shared/validate.ts b/scripts/ado-script/src/shared/validate.ts index b048153e..cb74ed88 100644 --- a/scripts/ado-script/src/shared/validate.ts +++ b/scripts/ado-script/src/shared/validate.ts @@ -30,11 +30,11 @@ export const PROJECT_RE = /^[A-Za-z0-9._ -]+$/; // Repository names have no spaces. export const REPO_RE = /^[A-Za-z0-9._-]+$/; -// PR target branch is interpolated into a git refspec +// PR target/source branches are interpolated into git refspecs // ("+refs/heads/:refs/remotes/origin/"), so it must be a // valid git branch name. The character set is what git itself accepts // for `refs/heads/`. -export const TARGET_BRANCH_RE = /^[A-Za-z0-9._/-]+$/; +export const PR_BRANCH_RE = /^[A-Za-z0-9._/-]+$/; export type IdentifierError = { /** A one-line reason, safe to embed in the agent prompt verbatim. */ @@ -46,8 +46,10 @@ export type Identifiers = { project: string; repo: string; targetBranch: string; + sourceBranch: string; /** The short branch name (`refs/heads/foo` -> `foo`). */ targetShort: string; + sourceShort: string; }; /** @@ -83,6 +85,7 @@ export function sanitizeForPrompt(value: string, maxLen = 80): string { export function validateIdentifiers(env: NodeJS.ProcessEnv): Identifiers | IdentifierError { const prId = env.SYSTEM_PULLREQUEST_PULLREQUESTID ?? ""; const targetBranch = env.SYSTEM_PULLREQUEST_TARGETBRANCH ?? ""; + const sourceBranch = env.SYSTEM_PULLREQUEST_SOURCEBRANCH ?? ""; const project = env.SYSTEM_TEAMPROJECT ?? ""; const repo = env.BUILD_REPOSITORY_NAME ?? ""; @@ -98,17 +101,28 @@ export function validateIdentifiers(env: NodeJS.ProcessEnv): Identifiers | Ident if (targetBranch.length === 0) { return { reason: "System.PullRequest.TargetBranch is empty; cannot resolve merge-base." }; } - if (!TARGET_BRANCH_RE.test(targetBranch)) { + if (!PR_BRANCH_RE.test(targetBranch)) { return { reason: `PR identifier validation failed (PR_TARGET_BRANCH='${sanitizeForPrompt(targetBranch)}' contains disallowed characters).`, }; } + if (sourceBranch.length === 0) { + return { reason: "System.PullRequest.SourceBranch is empty; cannot resolve merge-base." }; + } + if (!PR_BRANCH_RE.test(sourceBranch)) { + return { + reason: `PR identifier validation failed (PR_SOURCE_BRANCH='${sanitizeForPrompt(sourceBranch)}' contains disallowed characters).`, + }; + } const targetShort = targetBranch.startsWith("refs/heads/") ? targetBranch.slice("refs/heads/".length) : targetBranch; + const sourceShort = sourceBranch.startsWith("refs/heads/") + ? sourceBranch.slice("refs/heads/".length) + : sourceBranch; - return { prId, project, repo, targetBranch, targetShort }; + return { prId, project, repo, targetBranch, sourceBranch, targetShort, sourceShort }; } /** Type guard distinguishing the validated identifiers from an error. */ diff --git a/scripts/ado-script/test/smoke.test.ts b/scripts/ado-script/test/smoke.test.ts index ea20a3d1..122474b7 100644 --- a/scripts/ado-script/test/smoke.test.ts +++ b/scripts/ado-script/test/smoke.test.ts @@ -203,6 +203,7 @@ describe("exec-context-pr.js smoke", () => { AW_AGENT_PROMPT_FILE: agentPromptPath, SYSTEM_PULLREQUEST_PULLREQUESTID: "4242", SYSTEM_PULLREQUEST_TARGETBRANCH: "refs/heads/main", + SYSTEM_PULLREQUEST_SOURCEBRANCH: "refs/heads/feature", SYSTEM_TEAMPROJECT: "SmokeProject", BUILD_REPOSITORY_NAME: "smoke-repo", }, diff --git a/site/src/content/docs/reference/ado-script.mdx b/site/src/content/docs/reference/ado-script.mdx index 06a71e88..97d07597 100644 --- a/site/src/content/docs/reference/ado-script.mdx +++ b/site/src/content/docs/reference/ado-script.mdx @@ -126,7 +126,9 @@ Two paths, both computing the same "merge-base of target tip and PR head": 1. **Synthetic merge commit** — ADO's default PR checkout produces a merge commit (`HEAD` has ≥ 2 parents). `HEAD^1` is the target tip; `HEAD^2` is - the PR head. The bundle runs `git merge-base HEAD^1 HEAD^2`. + the PR head. The bundle runs `git merge-base HEAD^1 HEAD^2`; if that fails + in a shallow checkout, it fetches both target and source refs with + progressive deepening and retries before failing closed. 2. **Progressive deepening** — when `HEAD` is a normal commit (shallow clone without a merge commit), the bundle fetches the target branch at increasing depths (`--depth=200`, `500`, `2000`, `--unshallow`) until diff --git a/site/src/content/docs/reference/execution-context.mdx b/site/src/content/docs/reference/execution-context.mdx index e3063c20..c4371f86 100644 --- a/site/src/content/docs/reference/execution-context.mdx +++ b/site/src/content/docs/reference/execution-context.mdx @@ -353,13 +353,14 @@ The bundle: 2. **Validates identifiers** with strict allowlist regexes (PR_ID ⊆ digits; PROJECT/REPO ⊆ alphanumeric + `._-`; PROJECT additionally allows space; - PR_TARGET_BRANCH ⊆ alphanumeric + `._/-`). Writes `error.txt` and the failure - prompt fragment on failure. + PR_TARGET_BRANCH and PR_SOURCE_BRANCH ⊆ alphanumeric + `._/-`). Writes + `error.txt` and the failure prompt fragment on failure. 3. **Detects the merge-commit shape.** If `HEAD` has ≥ 3 tokens in `git rev-list --parents HEAD` (ADO's synthetic merge commit for PR builds), uses `HEAD^2` as - the PR head and computes `git merge-base HEAD^1 HEAD^2` — no target-branch - fetch needed. + the PR head and computes `git merge-base HEAD^1 HEAD^2`. If a shallow + checkout lacks enough ancestry, it fetches both target and source refs with + progressive deepening and retries; unresolved ancestry fails closed. 4. **Otherwise fetches the PR target branch** with progressive deepening: `--depth=200`, `500`, `2000`, then `--unshallow`. After each fetch, attempts `git merge-base diff --git a/src/compile/extensions/exec_context/pr.rs b/src/compile/extensions/exec_context/pr.rs index 40a5a66e..e9dd560c 100644 --- a/src/compile/extensions/exec_context/pr.rs +++ b/src/compile/extensions/exec_context/pr.rs @@ -165,6 +165,10 @@ impl ContextContributor for PrContextContributor { .with_env( "SYSTEM_PULLREQUEST_TARGETBRANCH", EnvValue::pipeline_var("AW_PR_TARGETBRANCH"), + ) + .with_env( + "SYSTEM_PULLREQUEST_SOURCEBRANCH", + EnvValue::pipeline_var("AW_PR_SOURCEBRANCH"), ); } Ok(Some(Step::Bash(step))) diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index b4ac518c..0578e191 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -6393,16 +6393,16 @@ Body. // ancestor (`git merge-base`), so `git diff $BASE..$HEAD` produces // the same change set regardless of which path runs. v7: this // invariant is now enforced by the `exec-context-pr.js` bundle (see -// `merge-base.ts::resolveMergeBase`); the vitest test -// `falls back to HEAD^1 when synthetic-merge merge-base cannot resolve` -// guards the regression there. This Rust-side test is removed — +// `merge-base.ts::resolveMergeBase`); the vitest tests for synthetic +// merge-base deepening and fail-closed behavior guard the regression +// there. This Rust-side test is removed — // asserting bash literals against a node-invocation step makes no // sense. -// v6.2 defence-in-depth: `PR_TARGET_BRANCH` (which gets interpolated -// into a git refspec) is validated with a strict allowlist regex. +// v6.2 defence-in-depth: PR target/source branches (which get +// interpolated into git refspecs) are validated with a strict allowlist regex. // v7: this validation now lives in the `exec-context-pr.js` bundle -// (see `validate.ts::TARGET_BRANCH_RE`); the vitest tests under +// (see `validate.ts::PR_BRANCH_RE`); the vitest tests under // `validate.test.ts` guard the regression there. This Rust-side // test is removed for the same reason. @@ -6451,6 +6451,11 @@ fn test_synthetic_pr_default_emits_full_synth_wiring() { AW_PR_ID via the $() macro (NOT a $[ ... ] runtime expression in step env — \ ADO doesn't evaluate those there, see build #612528)" ); + assert!( + compiled.contains("SYSTEM_PULLREQUEST_SOURCEBRANCH: $(AW_PR_SOURCEBRANCH)"), + "Fixture A's exec-context-pr step must receive the hoisted PR source ref so \ + synthetic-merge fallback deepening can fetch both PR parents" + ); // The Agent-job-level hoist itself must be present and pull from // the cross-job synth outputs (legal scope for `dependencies.X.outputs[...]`). for name in &[ From cbbcb21a8bbcee5417beb2150350b8ea6e931068 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:35:35 +0000 Subject: [PATCH 3/3] test: assert synthetic PR source branch hoist Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/extensions/exec_context/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/compile/extensions/exec_context/mod.rs b/src/compile/extensions/exec_context/mod.rs index d23f0f72..82d94901 100644 --- a/src/compile/extensions/exec_context/mod.rs +++ b/src/compile/extensions/exec_context/mod.rs @@ -996,7 +996,12 @@ mod tests { // `agentic_pipeline::agent_job_variables_hoist` populates in // production builds. Reproduce a minimal subset here. let synth_id = StepId::new("synthPr").unwrap(); - for name in &["AW_PR_ID", "AW_PR_TARGETBRANCH", "AW_SYNTHETIC_PR"] { + for name in &[ + "AW_PR_ID", + "AW_PR_TARGETBRANCH", + "AW_PR_SOURCEBRANCH", + "AW_SYNTHETIC_PR", + ] { agent_job.variables.push(JobVariable { name: (*name).into(), value: EnvValue::coalesce(vec![EnvValue::step_output(OutputRef::new( @@ -1041,6 +1046,10 @@ mod tests { yaml.contains("SYSTEM_PULLREQUEST_TARGETBRANCH: $(AW_PR_TARGETBRANCH)"), "target branch env must read hoisted AW_PR_TARGETBRANCH; got:\n{yaml}" ); + assert!( + yaml.contains("SYSTEM_PULLREQUEST_SOURCEBRANCH: $(AW_PR_SOURCEBRANCH)"), + "source branch env must read hoisted AW_PR_SOURCEBRANCH; got:\n{yaml}" + ); // Negative assertion: no cross-job `dependencies..outputs[...]` // ref must appear in the step's env block — that runtime // expression form is illegal at step-env scope (PR #956). The