Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/skills/agentic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This skill is a dispatcher: identify the task type, load the matching workflow p
Read only the files you need:
Load these files from `github/gh-aw` (they are not available locally).
- `.github/aw/agentic-chat.md`
- `.github/aw/agentic-workflows-mcp.md`
- `.github/aw/asciicharts.md`
- `.github/aw/campaign.md`
- `.github/aw/charts-trending.md`
Expand All @@ -27,6 +28,7 @@ Load these files from `github/gh-aw` (they are not available locally).
- `.github/aw/github-agentic-workflows.md`
- `.github/aw/github-mcp-server.md`
- `.github/aw/llms.md`
- `.github/aw/mcp-clis.md`
- `.github/aw/memory.md`
- `.github/aw/messages.md`
- `.github/aw/network.md`
Expand Down
11 changes: 8 additions & 3 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,16 @@ async function tryRecoverGitAmAddAddConflict(execApi) {
* @param {string} branchName - Target branch name
* @param {string} originalAgentBranch - Original source branch name from the agent, if different
* @param {{ exec: Function, getExecOutput: Function }} execApi - GitHub Actions exec API
* @param {string} [baseBranch] - Base branch name (used for iterative shallow-clone deepening)
* @returns {Promise<void>}
*/
async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi) {
async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi, baseBranch) {
let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`;
const bundleTargetRef = `refs/heads/${branchName}`;
const bundleTempRef = createBundleTempRef(branchName);

try {
await ensureFullHistoryForBundle(execApi);
await ensureFullHistoryForBundle(execApi, {}, { baseRef: baseBranch, bundleFilePath });
core.info(`Applying bundle ${bundleFilePath} to ${bundleTargetRef} using temp ref ${bundleTempRef} from ${bundleBranchRef}`);

// Fetch from bundle into a temporary ref, then update the target branch.
Expand Down Expand Up @@ -1478,7 +1479,7 @@ async function main(config = {}) {
// unlike git format-patch which flattens history and drops merge resolution content.
core.info(`Applying changes from bundle: ${bundleFilePath}`);
try {
await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec);
await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec, baseBranch);
} catch (bundleError) {
core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
return { success: false, error: "Failed to apply bundle" };
Expand All @@ -1502,6 +1503,7 @@ async function main(config = {}) {
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
validationConfig: config,
});
core.info("Changes pushed to branch (from bundle)");

Expand Down Expand Up @@ -1534,6 +1536,7 @@ async function main(config = {}) {
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
validationConfig: config,
});
core.info("Changes pushed to branch after bundle rewrite retry");

Expand Down Expand Up @@ -1865,6 +1868,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
validationConfig: config,
});
core.info("Changes pushed to branch");

Expand Down Expand Up @@ -2013,6 +2017,7 @@ ${patchPreview}`;
signedCommits,
resolvedTemporaryIds,
currentRepo: itemRepo,
validationConfig: config,
});
core.info("Empty branch pushed successfully");

Expand Down
18 changes: 15 additions & 3 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ describe("create_pull_request - bundle transport shallow checkout", () => {
if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") {
return Promise.resolve({ exitCode: 0, stdout: "true\n", stderr: "" });
}
if (cmd === "git" && args[0] === "bundle" && args[1] === "verify") {
// Declare a fake prerequisite so ensureFullHistoryForBundle proceeds to deepen.
return Promise.resolve({ exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${"a".repeat(40)}\n` });
}
if (cmd === "git" && args[0] === "merge-base" && args[1] === "--is-ancestor") {
// Report prereq missing initially → iterative deepen kicks in; after the
// first deepen fetch we still report missing so the fallback --unshallow
// path is exercised. The default mock for exec() resolves successfully,
// so all 7 deepen steps complete instantly before the fallback fires.
return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" });
}
if (cmd === "git" && args[0] === "rev-list") {
return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" });
}
Expand Down Expand Up @@ -225,7 +236,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => {
vi.clearAllMocks();
});

it("should fetch bundle without forcing an unshallow fetch", async () => {
it("should deepen origin/<base> before fetching bundle in shallow repositories", async () => {
const patchPath = path.join(tempDir, "test.patch");
fs.writeFileSync(
patchPath,
Expand Down Expand Up @@ -264,8 +275,9 @@ index 0000000..abc1234
expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature/test", bundleTempRef]);
expect(global.exec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"]);
const bundleFetchCallIndex = global.exec.getExecOutput.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath);
const unshallowCallIndex = global.exec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === "--unshallow");
expect(unshallowCallIndex).toBe(-1);
// Iterative deepen replaces a single --unshallow: assert the first --deepen step ran.
const deepenCallIndex = global.exec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen="));
expect(deepenCallIndex).toBeGreaterThanOrEqual(0);
expect(bundleFetchCallIndex).toBeGreaterThanOrEqual(0);
});

Expand Down
153 changes: 144 additions & 9 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,108 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) {
}

/**
* Probe shallow-repository status before fetching a git bundle.
* Deepen sequence (per call to `git fetch --deepen=N`). Each value adds N
* commits to the existing shallow history. Total reachable depth after the
* final step is the sum of these values (~7850 commits).
*/
const BUNDLE_DEEPEN_STEPS = [50, 100, 200, 500, 1000, 2000, 4000];

/**
* Extract prerequisite commit SHAs declared in a git bundle file.
*
* Runs `git bundle verify <file>` (with `ignoreReturnCode`) and parses the
* "The bundle requires this ref:" section as well as the
* "Repository lacks these prerequisite commits:" error block. Both formats
* list the prerequisite commit SHAs.
*
* @param {{ getExecOutput: Function }} execApi
* @param {string} bundleFilePath
* @param {Object} [options]
* @returns {Promise<string[]>} Deduplicated lowercase 40-char SHAs, or [] on failure.
*/
async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) {
try {
const { stdout, stderr } = await execApi.getExecOutput("git", ["bundle", "verify", bundleFilePath], { ...options, ignoreReturnCode: true, silent: true });
const combined = `${stdout || ""}\n${stderr || ""}`;
const prereqs = new Set();
const lines = combined.split(/\r?\n/);
let inRequires = false;
for (const line of lines) {
if (/the bundle (requires|records) (this|these)/i.test(line)) {
inRequires = true;
continue;
}
if (/the bundle contains/i.test(line)) {
inRequires = false;
continue;
}
if (inRequires) {
const match = line.match(/\b([0-9a-f]{40})\b/i);
if (match) {
prereqs.add(match[1].toLowerCase());
continue;
}
if (line.trim() === "") {
inRequires = false;
}
}
}
// Also pick up "Repository lacks these prerequisite commits:" block.
for (const sha of extractBundlePrerequisiteCommits(combined)) {
prereqs.add(sha);
}
return [...prereqs];
} catch (error) {
core.debug(`getBundlePrerequisites failed: ${getErrorMessage(error)}`);
return [];
}
}

/**
* Check which of the given SHAs are NOT yet ancestors of `targetRef`.
*
* @param {{ getExecOutput: Function }} execApi
* @param {string[]} shas
* @param {string} targetRef
* @param {Object} [options]
* @returns {Promise<string[]>} SHAs still missing (not ancestors / not present).
*/
async function findMissingAncestors(execApi, shas, targetRef, options = {}) {
const missing = [];
for (const sha of shas) {
const { exitCode } = await execApi.getExecOutput("git", ["merge-base", "--is-ancestor", sha, targetRef], { ...options, ignoreReturnCode: true, silent: true });
if (exitCode !== 0) {
missing.push(sha);
}
}
return missing;
}

/**
* Probe shallow-repository status before fetching a git bundle, and deepen
* the local clone as needed so the bundle's prerequisite commits become
* reachable from `origin/<baseRef>`.
*
* Bundles generated from a commit range can declare prerequisite commits. A
* depth-1 checkout may not contain those prerequisites, and `git fetch <bundle>`
* can reject the bundle before the caller can update refs.
* shallow checkout (e.g. `fetch-depth: 20`) may not contain those prerequisites,
* and `git fetch <bundle>` will reject the bundle before the caller can update
* refs. On a high-churn monorepo, `git fetch --unshallow` is catastrophic — it
* downloads the entire history. Instead we iterate `git fetch origin <baseRef>
* --deepen=<N>` with progressively larger N until every declared prerequisite
* satisfies `git merge-base --is-ancestor <prereq> origin/<baseRef>`.
*
* IMPORTANT: Do not unshallow here. Full-history fetches are prohibitively
* expensive for large monorepos. Callers recover from prerequisite failures by
* fetching only the missing commit objects from origin and retrying.
* When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing
* (legacy callers), the function falls back to the previous behavior of a
* single `git fetch --unshallow origin`.
*
* @param {{ getExecOutput: Function, exec: Function }} execApi - Exec API to run git commands.
* @param {Object} [options] - Options passed through to exec calls.
* @param {Object} [deepenOptions]
* @param {string} [deepenOptions.baseRef] - Remote branch name to deepen (no `origin/` prefix).
* @param {string} [deepenOptions.bundleFilePath] - Path to the bundle file whose prerequisites must become reachable.
* @returns {Promise<void>}
*/
async function ensureFullHistoryForBundle(execApi, options = {}) {
async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = {}) {
let stdout;
try {
({ stdout } = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], options));
Expand All @@ -175,8 +262,56 @@ async function ensureFullHistoryForBundle(execApi, options = {}) {
core.warning(`Could not determine shallow repository status; skipping full-history fetch probe: ${message}`);
return;
}
if (stdout.trim() === "true") {
core.info("Repository is shallow; skipping full-history fetch and relying on prerequisite recovery");
if (stdout.trim() !== "true") {
return;
}

const { baseRef, bundleFilePath } = deepenOptions || {};

// Legacy path: no base ref / bundle info known — fall back to a single
// unshallow. Callers in monorepos should always supply baseRef + bundleFilePath
// to get incremental deepening instead.
if (!baseRef || !bundleFilePath) {
core.info("Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)");
await execApi.exec("git", ["fetch", "--unshallow", "origin"], options);
return;
}

const prereqs = await getBundlePrerequisites(execApi, bundleFilePath, options);
if (prereqs.length === 0) {
core.info("Bundle declares no prerequisites; no deepen required");
return;
}

const targetRef = `origin/${baseRef}`;
const alreadyMissing = await findMissingAncestors(execApi, prereqs, targetRef, options);
if (alreadyMissing.length === 0) {
core.info(`Bundle prerequisites already reachable from ${targetRef}; no deepen required`);
return;
}

core.info(`Repository is shallow; iteratively deepening ${targetRef} to satisfy ${alreadyMissing.length} bundle prerequisite commit(s)`);
let missing = alreadyMissing;
for (const depth of BUNDLE_DEEPEN_STEPS) {
core.info(`Fetching origin ${baseRef} with --deepen=${depth} (${missing.length} prerequisite(s) still missing)`);
try {
await execApi.exec("git", ["fetch", `--deepen=${depth}`, "origin", baseRef], options);
} catch (fetchError) {
core.warning(`git fetch --deepen=${depth} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`);
break;
}
missing = await findMissingAncestors(execApi, prereqs, targetRef, options);
if (missing.length === 0) {
core.info(`Bundle prerequisites reachable after --deepen=${depth}`);
return;
}
}

core.warning(`Bundle prerequisites still not reachable after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`);
try {
await execApi.exec("git", ["fetch", "--unshallow", "origin", baseRef], options);
} catch (unshallowError) {
core.warning(`Fallback --unshallow fetch failed: ${getErrorMessage(unshallowError)}; bundle apply may still fail`);
}
}

Expand Down
84 changes: 82 additions & 2 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ describe("git_helpers.cjs", () => {
});

describe("ensureFullHistoryForBundle", () => {
it("should not fetch full history when the repository is shallow", async () => {
it("should unshallow the repository when the repository is shallow", async () => {
const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs");
const execApi = {
getExecOutput: vi.fn().mockResolvedValue({ stdout: "true\n" }),
Expand All @@ -294,7 +294,7 @@ describe("git_helpers.cjs", () => {
await ensureFullHistoryForBundle(execApi, options);

expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["rev-parse", "--is-shallow-repository"], options);
expect(execApi.exec).not.toHaveBeenCalled();
expect(execApi.exec).toHaveBeenCalledWith("git", ["fetch", "--unshallow", "origin"], options);
});

it("should not fetch full history when the repository is not shallow", async () => {
Expand Down Expand Up @@ -338,6 +338,86 @@ describe("git_helpers.cjs", () => {
expect(warning).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledWith("Could not determine shallow repository status; skipping full-history fetch probe: unknown failure");
});

it("should iteratively deepen origin/<base> when bundle prereqs are known and shallow", async () => {
const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs");
const prereq = "a".repeat(40);
let deepenCalls = 0;
const execApi = {
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") {
return Promise.resolve({ stdout: "true\n" });
}
if (args[0] === "bundle" && args[1] === "verify") {
return Promise.resolve({
stdout: "",
stderr: `The bundle requires this ref:\n${prereq}\n`,
exitCode: 1,
});
}
if (args[0] === "merge-base" && args[1] === "--is-ancestor") {
// Become reachable only after the second deepen fetch.
return Promise.resolve({ exitCode: deepenCalls >= 2 ? 0 : 1, stdout: "", stderr: "" });
}
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}),
exec: vi.fn().mockImplementation((cmd, args) => {
if (args && args[0] === "fetch" && args[1] && args[1].startsWith("--deepen=")) {
deepenCalls++;
}
return Promise.resolve(0);
}),
};

await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" });

// Two deepen fetches before ancestry succeeds; no --unshallow.
const fetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch");
expect(fetchCalls.length).toBe(2);
expect(fetchCalls[0][1]).toEqual(["fetch", "--deepen=50", "origin", "main"]);
expect(fetchCalls[1][1]).toEqual(["fetch", "--deepen=100", "origin", "main"]);
expect(execApi.exec).not.toHaveBeenCalledWith("git", ["fetch", "--unshallow", "origin"], expect.anything());
});

it("should skip deepening when bundle declares no prerequisites", async () => {
const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs");
const execApi = {
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
if (args[0] === "rev-parse") return Promise.resolve({ stdout: "true\n" });
if (args[0] === "bundle" && args[1] === "verify") {
return Promise.resolve({ stdout: "The bundle contains this ref:\ndeadbeef refs/heads/x\n", stderr: "", exitCode: 0 });
}
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}),
exec: vi.fn().mockResolvedValue(0),
};

await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" });

expect(execApi.exec).not.toHaveBeenCalled();
});

it("should skip deepening when prereqs are already reachable from origin/<base>", async () => {
const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs");
const prereq = "b".repeat(40);
const execApi = {
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
if (args[0] === "rev-parse") return Promise.resolve({ stdout: "true\n" });
if (args[0] === "bundle" && args[1] === "verify") {
return Promise.resolve({ stdout: `The bundle requires this ref:\n${prereq}\n`, stderr: "", exitCode: 0 });
}
if (args[0] === "merge-base" && args[1] === "--is-ancestor") {
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}),
exec: vi.fn().mockResolvedValue(0),
};

await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" });

expect(execApi.exec).not.toHaveBeenCalled();
});
});

describe("isShallowOrSparseCheckout", () => {
Expand Down
Loading
Loading