diff --git a/src/backport.ts b/src/backport.ts index 5870dde..3b5375f 100644 --- a/src/backport.ts +++ b/src/backport.ts @@ -3,7 +3,7 @@ import dedent from "dedent"; import { CreatePullRequestResponse, PullRequest } from "./github"; import { GithubApi } from "./github"; -import * as git from "./git"; +import { Git, GitRefNotFoundError } from "./git"; import * as utils from "./utils"; type PRContent = { @@ -32,10 +32,12 @@ enum Output { export class Backport { private github; private config; + private git; - constructor(github: GithubApi, config: Config) { + constructor(github: GithubApi, config: Config, git: Git) { this.github = github; this.config = config; + this.git = git; } async run(): Promise { @@ -70,7 +72,7 @@ export class Backport { console.log( `Fetching all the commits from the pull request: ${mainpr.commits + 1}`, ); - await git.fetch( + await this.git.fetch( `refs/pull/${pull_number}/head`, this.config.pwd, mainpr.commits + 1, // +1 in case this concerns a shallowly cloned repo @@ -104,9 +106,9 @@ export class Backport { console.log(`Backporting to target branch '${target}...'`); try { - await git.fetch(target, this.config.pwd, 1); + await this.git.fetch(target, this.config.pwd, 1); } catch (error) { - if (error instanceof git.GitRefNotFoundError) { + if (error instanceof GitRefNotFoundError) { const message = this.composeMessageForFetchTargetFailure(error.ref); console.error(message); successByTarget.set(target, false); @@ -127,7 +129,11 @@ export class Backport { console.log(`Start backport to ${branchname}`); try { - await git.checkout(branchname, `origin/${target}`, this.config.pwd); + await this.git.checkout( + branchname, + `origin/${target}`, + this.config.pwd, + ); } catch (error) { const message = this.composeMessageForBackportScriptFailure( target, @@ -148,7 +154,7 @@ export class Backport { } try { - await git.cherryPick(commitShas, this.config.pwd); + await this.git.cherryPick(commitShas, this.config.pwd); } catch (error) { const message = this.composeMessageForBackportScriptFailure( target, @@ -169,7 +175,7 @@ export class Backport { } console.info(`Push branch to origin`); - const pushExitCode = await git.push(branchname, this.config.pwd); + const pushExitCode = await this.git.push(branchname, this.config.pwd); if (pushExitCode != 0) { const message = this.composeMessageForGitPushFailure( target, diff --git a/src/git.ts b/src/git.ts index 2cdd83b..e59a3f4 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,4 +1,4 @@ -import { execa } from "execa"; +export type Execa = (typeof import("execa"))["execa"]; export class GitRefNotFoundError extends Error { ref: string; @@ -8,71 +8,79 @@ export class GitRefNotFoundError extends Error { } } -/** - * Fetches a ref from origin - * - * @param ref the sha, branchname, etc to fetch - * @param pwd the root of the git repository - * @param depth the number of commits to fetch - * @throws GitRefNotFoundError when ref not found - * @throws Error for any other non-zero exit code - */ -export async function fetch(ref: string, pwd: string, depth: number) { - const { exitCode } = await git( - "fetch", - [`--depth=${depth}`, "origin", ref], - pwd, - ); - if (exitCode === 128) { - throw new GitRefNotFoundError( - `Expected to fetch '${ref}', but couldn't find it`, - ref, - ); - } else if (exitCode !== 0) { - throw new Error( - `'git fetch origin ${ref}' failed with exit code ${exitCode}`, - ); - } -} +export class Git { + constructor(private execa: Execa) {} -export async function push(branchname: string, pwd: string) { - const { exitCode } = await git( - "push", - ["--set-upstream", "origin", branchname], - pwd, - ); - return exitCode; -} + private async git(command: string, args: string[], pwd: string) { + console.log(`git ${command} ${args.join(" ")}`); + const child = this.execa("git", [command, ...args], { + cwd: pwd, + env: { + GIT_COMMITTER_NAME: "github-actions[bot]", + GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com", + }, + reject: false, + }); + child.stderr?.pipe(process.stderr); + return child; + } -async function git(command: string, args: string[], pwd: string) { - console.log(`git ${command} ${args.join(" ")}`); - const child = execa("git", [command, ...args], { - cwd: pwd, - env: { - GIT_COMMITTER_NAME: "github-actions[bot]", - GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com", - }, - reject: false, - }); - child.stderr?.pipe(process.stderr); - return child; -} + /** + * Fetches a ref from origin + * + * @param ref the sha, branchname, etc to fetch + * @param pwd the root of the git repository + * @param depth the number of commits to fetch + * @throws GitRefNotFoundError when ref not found + * @throws Error for any other non-zero exit code + */ + public async fetch(ref: string, pwd: string, depth: number) { + const { exitCode } = await this.git( + "fetch", + [`--depth=${depth}`, "origin", ref], + pwd, + ); + if (exitCode === 128) { + throw new GitRefNotFoundError( + `Expected to fetch '${ref}', but couldn't find it`, + ref, + ); + } else if (exitCode !== 0) { + throw new Error( + `'git fetch origin ${ref}' failed with exit code ${exitCode}`, + ); + } + } -export async function checkout(branch: string, start: string, pwd: string) { - const { exitCode } = await git("switch", ["-c", branch, start], pwd); - if (exitCode !== 0) { - throw new Error( - `'git switch -c ${branch} ${start}' failed with exit code ${exitCode}`, + public async push(branchname: string, pwd: string) { + const { exitCode } = await this.git( + "push", + ["--set-upstream", "origin", branchname], + pwd, ); + return exitCode; + } + + public async checkout(branch: string, start: string, pwd: string) { + const { exitCode } = await this.git("switch", ["-c", branch, start], pwd); + if (exitCode !== 0) { + throw new Error( + `'git switch -c ${branch} ${start}' failed with exit code ${exitCode}`, + ); + } } -} -export async function cherryPick(commitShas: string[], pwd: string) { - const { exitCode } = await git("cherry-pick", ["-x", ...commitShas], pwd); - if (exitCode !== 0) { - await git("cherry-pick", ["--abort"], pwd); - throw new Error( - `'git cherry-pick -x ${commitShas}' failed with exit code ${exitCode}`, + public async cherryPick(commitShas: string[], pwd: string) { + const { exitCode } = await this.git( + "cherry-pick", + ["-x", ...commitShas], + pwd, ); + if (exitCode !== 0) { + await this.git("cherry-pick", ["--abort"], pwd); + throw new Error( + `'git cherry-pick -x ${commitShas}' failed with exit code ${exitCode}`, + ); + } } } diff --git a/src/main.ts b/src/main.ts index f995c70..d69ee5e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ import * as core from "@actions/core"; import { Backport } from "./backport"; import { Github } from "./github"; +import { Git } from "./git"; +import { execa } from "execa"; /** * Called from the action.yml. @@ -17,7 +19,8 @@ async function run(): Promise { const target_branches = core.getInput("target_branches"); const github = new Github(token); - const backport = new Backport(github, { + const git = new Git(execa); + const config = { pwd, labels: { pattern: pattern === "" ? undefined : new RegExp(pattern) }, pull: { description, title }, @@ -25,7 +28,8 @@ async function run(): Promise { copy_labels_pattern === "" ? undefined : new RegExp(copy_labels_pattern), target_branches: target_branches === "" ? undefined : (target_branches as string), - }); + }; + const backport = new Backport(github, config, git); return backport.run(); } diff --git a/src/test/backport.test.ts b/src/test/backport.test.ts index 06b9634..bf9317d 100644 --- a/src/test/backport.test.ts +++ b/src/test/backport.test.ts @@ -1,9 +1,5 @@ import { findTargetBranches } from "../backport"; -jest.mock("execa", () => ({ - execa: jest.fn(), -})); - const default_pattern = /^backport ([^ ]+)$/; describe("find target branches", () => { diff --git a/src/test/git.test.ts b/src/test/git.test.ts new file mode 100644 index 0000000..e3e9ff5 --- /dev/null +++ b/src/test/git.test.ts @@ -0,0 +1,46 @@ +import { Git, GitRefNotFoundError } from "../git"; +import { execa } from "execa"; + +const git = new Git(execa); +let response = { exitCode: 0, stdout: "" }; + +jest.mock("execa", () => ({ + execa: jest.fn(() => response), +})); + +describe("git.fetch", () => { + describe("throws GitRefNotFoundError", () => { + it("when fetching an unknown ref, i.e. exit code 128", async () => { + response.exitCode = 128; + expect.assertions(3); + await git.fetch("unknown", "", 1).catch((error) => { + expect(error).toBeInstanceOf(GitRefNotFoundError); + expect(error).toHaveProperty( + "message", + "Expected to fetch 'unknown', but couldn't find it", + ); + expect(error).toHaveProperty("ref", "unknown"); + }); + }); + }); + + describe("throws Error", () => { + it("when failing with an unexpected non-zero exit code", async () => { + response.exitCode = 1; + await expect(git.fetch("unknown", "", 1)).rejects.toThrowError( + `'git fetch origin unknown' failed with exit code 1`, + ); + }); + }); +}); + +describe("git.cherryPick", () => { + describe("throws Error", () => { + it("when failing with an unexpected non-zero exit code", async () => { + response.exitCode = 1; + await expect(git.cherryPick(["unknown"], "")).rejects.toThrowError( + `'git cherry-pick -x unknown' failed with exit code 1`, + ); + }); + }); +});