Skip to content
Merged
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
96 changes: 94 additions & 2 deletions actions/setup/js/bundle_transport.integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import http from "http";
import os from "os";
import path from "path";

const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions } = require("./git_helpers.cjs");
const { resolveTrustedBaseTip, getBundleValidationBase, getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs");
const gitHelpers = require("./git_helpers.cjs");
const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions } = gitHelpers;
const { resolveTrustedBaseTip, resolveBundleTrustedBase, getBundleValidationBase, getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs");
const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");

const gitEnv = () => {
Expand All @@ -28,6 +29,10 @@ function execApi(cwd, { emulateOldGit = false } = {}) {
const calls = [];
const api = {
calls,
async execGitSyncResult(args, options) {
const result = await api.getExecOutput("git", args, { ...options, silent: true, ignoreReturnCode: true });
return { ...result, status: result.exitCode };
},
async getExecOutput(command, args, options = {}) {
calls.push(args);
const env = { ...gitEnv(), ...options.env };
Expand Down Expand Up @@ -109,6 +114,49 @@ describe("offline bundle transport with real Git", () => {
return { oldHead, trustedBase, candidate };
}

it("recognizes the newest merged base without fetching an advanced trusted tip", async () => {
git(source, ["checkout", "-b", "feature"]);
const oldHead = addCommit("existing.txt", "PR work\n");
git(source, ["checkout", "main"]);
const olderBase = addCommit("older.txt", "older upstream\n");
git(source, ["checkout", "feature"]);
git(source, ["merge", "--no-ff", "main", "-m", "merge older base"]);
git(source, ["checkout", "main"]);
const mergedBase = addCommit("package.json", '{"upstream":true}\n');
git(source, ["checkout", "feature"]);
addCommit("agent.txt", "agent work\n");
git(source, ["merge", "--no-ff", "main", "-m", "merge newer base"]);
const bundle = path.join(root, "agent.bundle");
git(source, ["bundle", "create", bundle, "feature", `^${oldHead}`]);
const target = path.join(root, "target");
git(root, ["clone", source, target]);
git(source, ["checkout", "main"]);
const trustedTip = addCommit("later.txt", "base advances after agent finishes\n");
const exec = execApi(target);
await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/test");
const before = packInventory(target);
expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(trustedTip);
const compareCommits = vi.fn(async ({ base, head }) => {
expect(base).toBe(trustedTip);
const result = spawnSync("git", ["merge-base", "--is-ancestor", head, base], { cwd: source, env: gitEnv() });
return { data: { status: result.status === 0 ? "behind" : "diverged" } };
});
const recognized = await resolveBundleTrustedBase(exec, {}, oldHead, "refs/bundles/test", trustedTip, { rest: { repos: { compareCommits } } }, { owner: "test-owner", repo: "test-repo" }, true);
expect(recognized).toBe(mergedBase);
expect(compareCommits.mock.calls.some(([args]) => args.head === olderBase)).toBe(false);
const baseline = await getBundleValidationBase(exec, {}, oldHead, "refs/bundles/test", recognized);
const files = await getBundlePreApplyFiles(exec, {}, baseline.ref, "refs/bundles/test", baseline.conflictedFiles);
expect(files).toEqual(["agent.txt"]);
expect(checkFileProtectionPostApply(files, { protected_files: ["package.json"] }).action).toBe("allow");
// Reachability permits the size baseline, not a protected-file exemption.
const protectionFiles = await getBundlePreApplyFiles(exec, {}, oldHead, "refs/bundles/test", baseline.conflictedFiles);
expect(checkFileProtectionPostApply(protectionFiles, { protected_files: ["package.json"] }).action).toBe("deny");
const absent = await exec.getExecOutput("git", ["cat-file", "-e", trustedTip], { ...offlineGitOptions(), ignoreReturnCode: true });
expect(absent.exitCode).not.toBe(0);
expect(packInventory(target)).toEqual(before);
expect(exec.calls.some(args => args[0] === "fetch")).toBe(false);
});

it.each([false, true])("does not lazily transfer blobs while inspecting a partial clone (old Git guard: %s)", async emulateOldGit => {
const { oldHead, trustedBase, candidate } = mergeFixture();
git(source, ["config", "uploadpack.allowFilter", "true"]);
Expand Down Expand Up @@ -145,6 +193,50 @@ describe("offline bundle transport with real Git", () => {
expect(fs.readFileSync(path.join(shallow, ".git/shallow"), "utf8")).toBe(shallowFile);
});

it.each([false, true])("keeps advanced-base discovery offline in a shallow checkout (filter support: %s)", async allowFilter => {
const oldHead = git(source, ["rev-parse", "HEAD"]);
git(source, ["checkout", "-b", "feature"]);
git(source, ["config", "uploadpack.allowFilter", String(allowFilter)]);
const shallow = path.join(root, "shallow");
git(root, ["clone", "--depth=1", "--filter=blob:none", "--no-checkout", "--branch", "feature", `file://${source}`, shallow]);
const shallowPath = path.join(shallow, ".git/shallow");
const shallowFile = fs.readFileSync(shallowPath, "utf8");
git(source, ["checkout", "main"]);
const mergedBase = addCommit("upstream.txt", "upstream\n");
git(source, ["checkout", "feature"]);
git(source, ["merge", "--no-ff", "main", "-m", "merge provisioned base"]);
addCommit("agent.txt", "agent\n");
const bundle = path.join(root, "shallow-agent.bundle");
git(source, ["bundle", "create", bundle, "feature", `^${oldHead}`]);
git(source, ["checkout", "main"]);
const trustedTip = addCommit("later.txt", "advanced base tip\n");

const exec = execApi(shallow);
await ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle });
await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/test");
const before = packInventory(shallow);
const compareCommits = vi.fn(async () => ({ data: { status: "behind" } }));
const options = { cwd: shallow, env: gitEnv() };
expect(await resolveTrustedBaseTip(gitHelpers, options, "main")).toBe(trustedTip);
const recognized = await resolveBundleTrustedBase(gitHelpers, options, oldHead, "refs/bundles/test", trustedTip, { rest: { repos: { compareCommits } } }, { owner: "test-owner", repo: "test-repo" }, true);
expect(recognized).toBe(mergedBase);
await getBundleValidationBase(gitHelpers, options, oldHead, "refs/bundles/test", recognized);

expect(compareCommits).toHaveBeenCalledTimes(1);
expect(exec.calls.some(args => args[0] === "fetch")).toBe(false);
expect(git(shallow, ["rev-parse", "--is-shallow-repository"])).toBe("true");
expect(fs.readFileSync(shallowPath, "utf8")).toBe(shallowFile);
expect(packInventory(shallow)).toEqual(before);
expect(fs.readdirSync(shallow)).toEqual([".git"]);
const absent = gitHelpers.execGitSyncResult(["cat-file", "-e", trustedTip], offlineGitOptions(options));
expect(absent.status).not.toBe(0);
if (allowFilter) {
const blob = git(source, ["rev-parse", `${oldHead}:shared.txt`]);
expect(gitHelpers.execGitSyncResult(["cat-file", "-e", blob], offlineGitOptions(options)).status).not.toBe(0);
}
expect(packInventory(shallow)).toEqual(before);
});

it("imports an incremental bundle into a shallow checkout with locally provisioned prerequisites", async () => {
const prerequisite = git(source, ["rev-parse", "HEAD"]);
const shallow = path.join(root, "shallow");
Expand Down
49 changes: 32 additions & 17 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { spawnSync } = require("child_process");
const childProcess = require("child_process");
const { ERR_SYSTEM } = require("./error_codes.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { isTransientError } = require("./error_recovery.cjs");
Expand Down Expand Up @@ -81,6 +81,35 @@ function ensureSafeDirectoryTrust(gitCwd, logger) {
debug?.(`Configured git safe.directory for bridge context: ${gitCwd}`);
}

/**
* Execute Git without logging command output, preserving ordinary exit statuses.
* Spawn failures discard partial output; callers must check status before use.
*
* @param {string[]} args
* @param {Object} [options]
* @returns {import("child_process").SpawnSyncReturns<string>}
*/
function execGitSyncResult(args, options = {}) {
const result = childProcess.spawnSync("git", args, {
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024,
timeout: 60_000,
killSignal: "SIGKILL",
...options,
stdio: options.stdio === "inherit" ? "pipe" : options.stdio,
env: {
...(options.env || process.env),
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "Never",
GIT_ASKPASS: "/bin/echo",
},
});
if (result.error || result.signal || result.status === null) {
return { ...result, status: null, stdout: "", stderr: "" };
}
return result;
}

/**
* Safely execute git command using spawnSync with args array to prevent shell injection.
*
Expand Down Expand Up @@ -119,24 +148,9 @@ function execGitSync(args, options = {}) {
// credential rejection fail fast instead of opening an interactive prompt.
// - timeout (default 60s) ensures a stuck network/TLS handshake cannot
// wedge the calling event loop. Callers can override via options.timeout.
const callerEnv = spawnOptions.env || process.env;
const safeEnv = {
...callerEnv,
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "Never",
GIT_ASKPASS: "/bin/echo",
};
const defaultTimeoutMs = 60_000;

const result = spawnSync("git", args, {
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024, // 100 MB — prevents ENOBUFS on large diffs (e.g. git format-patch)
timeout: defaultTimeoutMs,
killSignal: "SIGKILL",
...spawnOptions,
stdio: spawnOptions.stdio === "inherit" ? "pipe" : spawnOptions.stdio,
env: safeEnv,
});
const result = execGitSyncResult(args, spawnOptions);

if (result.error) {
// Detect ENOBUFS (buffer overflow) and provide a more actionable message
Expand Down Expand Up @@ -873,6 +887,7 @@ module.exports = {
isTransientGitError,
withGitRetry,
execGitSync,
execGitSyncResult,
backfillCommitObjects,
checkImplausibleShallowRange,
ensureFullHistoryForBundle,
Expand Down
20 changes: 20 additions & 0 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ describe("git_helpers.cjs", () => {
});

describe("execGitSync", () => {
it("exposes structured Git results without logging remote diagnostics", async () => {
const { execGitSyncResult } = await import("./git_helpers.cjs");
const logger = { debug: vi.fn(), error: vi.fn() };
global.core = logger;
const result = execGitSyncResult(["invalid-command"]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("invalid-command");
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});

it("discards partial structured output on process failure", async () => {
const { execGitSyncResult } = await import("./git_helpers.cjs");
const result = execGitSyncResult(["--version"], { maxBuffer: 1 });
expect(result.status).toBeNull();
expect(result.error.code).toBe("ENOBUFS");
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
});

it("should export execGitSync function", async () => {
const { execGitSync } = await import("./git_helpers.cjs");
expect(typeof execGitSync).toBe("function");
Expand Down
Loading
Loading