Skip to content

Commit

Permalink
fix(backmerge): checkout backmerged branches with commit hash to avoi…
Browse files Browse the repository at this point in the history
…d `Updates were rejected because the tip of your current branch is behind` git error on push - fixes #23

Signed-off-by: kilianpaquier <kilian@kilianpaquier.com>
  • Loading branch information
kilianpaquier committed Oct 26, 2024
1 parent ccabb07 commit 80c9ced
Show file tree
Hide file tree
Showing 8 changed files with 502 additions and 367 deletions.
61 changes: 52 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Logger, executeBackmerge, getBranches } from "./lib/backmerge"
import { Branch, checkout, fetch, ls, version } from "./lib/git"
import { Logger, backmerge, filter } from "./lib/backmerge"
import { PlatformHandler, newPlatformHandler } from "./lib/platform-handler"
import { SuccessContext, VerifyConditionsContext } from 'semantic-release'
import { ensureDefault, verifyConfig } from "./lib/verify-config"

import SemanticReleaseError from "@semantic-release/error"
import debug from "debug"

import { BackmergeConfig } from "./lib/models/config"
import { version } from "./lib/git"

/**
* prefix needs to be semantic-release:
* @see https://github.com/semantic-release/semantic-release/blob/8940f32ccce455a01a4e32c101bb0f4a809ab00d/cli.js#L52
*/
const deblog = debug("semantic-release:backmerge")

/**
* verifyConditions is the exported function for semantic-release for verifyConditions lifecycle.
Expand Down Expand Up @@ -49,15 +56,51 @@ export const verifyConditions = async (globalConfig: BackmergeConfig, context: V
* @param context the semantic-release context.
*/
export const success = async (globalConfig: BackmergeConfig, context: SuccessContext) => {
const [config, platformHandler] = await verifyConditions(globalConfig, context)
const logger = context.logger as Logger
const [config, handler] = await verifyConditions(globalConfig, context)

// filter targets to find if the current released branch needs to be backmerged into others
const targets = config.targets.filter(branch => context.branch.name.match(branch.from))
if (targets.length === 0) {
logger.log(`Current branch '${context.branch.name}' doesn't match any configured backmerge targets.`)
return
}
logger.log(`Current branch '${context.branch.name}' matches following configured backmerge targets: '${JSON.stringify(targets)}'.`)

// fetch remote and retrieve all remote branches
const branches: Branch[] = []
try {
// ensure at any time and any moment that the fetch'ed remote url is the same as there
// https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69
// it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits
await fetch(config.repositoryUrl, context.cwd, context.env)
branches.push(...await ls(config.repositoryUrl, context.cwd, context.env))
} catch (error) {
throw new SemanticReleaseError("Failed to fetch git remote or list all branches.", "EFECTHLISTREMOTE", String(error))
}

// retrieve current released branch informations
const release = branches.find(branch => branch.name === context.branch.name)
if (!release) {
deblog("released branch is not present in branches fetched from git '%j'", branches)
throw new SemanticReleaseError("Failed to retrieve released branch last commit hash. This shouldn't happen.", "ELISTREMOTE")
}

// filter targets branch with the known remote branches
// and with different business rules like vX.X, etc.
const mergeables = filter(release, targets, branches)
if (mergeables.length === 0) { // released branch is contained in branches
logger.log("No configured target is present in remote origin, no backmerge to be done.")
return // stop treatment since there's no branch to backmerge
}

// checkout to ensure released branch is up to date with last fetch'ed remote url
try {
const branches = await getBranches(context, config, platformHandler)
await executeBackmerge(context, config, platformHandler, branches)
await checkout(release, context.cwd, context.env)
} catch (error) {
if (error instanceof AggregateError || error instanceof SemanticReleaseError) {
throw error // don't wrap error in case it's already an acceptable error by semantic-release
}
throw new SemanticReleaseError("Failed to list or backmerge branches.", "EBACKMERGE", String(error))
throw new SemanticReleaseError(`Failed to checkout released branch '${release.name}'.`, "EFETCHCHECKOUT", String(error))
}

logger.log(`Performing backmerge of '${release.name}' on following branches: '${JSON.stringify(mergeables)}'.`)
await backmerge(context, config, handler, release, mergeables)
}
127 changes: 44 additions & 83 deletions lib/backmerge.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Branch, authModificator, checkout, merge, push } from "./git"
import { LastRelease, NextRelease } from "semantic-release"
import { authModificator, checkout, fetch, ls, merge, push } from "./git"

import AggregateError from "aggregate-error"
import SemanticReleaseError from "@semantic-release/error"
import debug from "debug"
import parse from "git-url-parse"
import semver from "semver"

import { BackmergeConfig } from "./models/config"
import { BackmergeConfig, Target } from "./models/config"
import { PlatformHandler } from "./platform-handler"
import { template } from "lodash"

Expand Down Expand Up @@ -39,58 +39,32 @@ export interface Context {
}

/**
* getBranches returns the slice of branches that can be backmerged.
* filter removes from input branches the ones not appropriates for backmerging with the input released branch.
*
* To retrieve them, it takes into account their existence in remote repository, their presence in input targets,
* and their semver version value related to the appropriate target (for instance, a branch v1.0 won't be returned if target.from is v1.1).
* Not being appropriate is not being configured in the input targets list
* or being too old in case of maintenance branches.
*
* @param context with logger, released branch, current directory and environment.
* @param config the semantic-release-backmerge plugin configuration.
* @param platformHandler the interface to handle current git platform API calls.
*
* @throws an error in case the input remote can't be fetched or the branches can be retrieved with git.
* @param release the released branch.
* @param targets the input targets configurations in semantic-release-backmerge.
* @param branches the list of branches potentially backmergeable.
*
* @returns the slice of branches where the context.branch.name must be backmerged into.
* @returns the list of branches to backmerge the released one into.
*/
export const getBranches = async (context: Context, config: BackmergeConfig, platformHandler: PlatformHandler) => {
const releaseBranch = context.branch.name

const appropriates = config.targets.filter(branch => releaseBranch.match(branch.from))
if (appropriates.length === 0) {
context.logger.log(`Current branch '${releaseBranch}' doesn't match any configured backmerge targets.`)
return []
}
context.logger.log(`Current branch '${releaseBranch}' matches following configured backmerge targets: '${JSON.stringify(appropriates)}'. Performing backmerge.`)

const url = parse(config.repositoryUrl)
const authRemote = authModificator(url, platformHandler.gitUser(), config.token)

let branches: string[] = [] // eslint-disable-line no-useless-assignment
try {
// ensure at any time and any moment that the fetch'ed remote url is the same as there
// https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69
// it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits
await fetch(authRemote, context.cwd, context.env)

branches = await ls(config.repositoryUrl, context.cwd, context.env)
} catch (error) {
throw new SemanticReleaseError("Failed to fetch git remote or list all branches.", "EFECTHLIST", String(error))
}

export const filter = (release: Branch, targets: Target[], branches: Branch[]): Branch[] => {
deblog("filtering branch to backmerge from '%j'", branches)
const filteredBranches = branches.
const mergeables = branches.
// don't keep the released branch
filter(branch => releaseBranch !== branch).
filter(branch => branch.name !== release.name).

// don't keep branches that doesn't match 'to' regexp
filter(branch => appropriates.map(target => target.to).find(target => branch.match(target))).
filter(branch => targets.map(target => target.to).find(target => branch.name.match(target))).

// only keep upper version when it's a semver released branch
// for instance v1 must not backmerge into anyone
// for instance v1.5 must backmerge into v1.6, v1.7, etc.
// for instance v1.5 must only backmerge into v1.6, v1.7, etc.
filter(branch => {
const releaseMaintenance = semver.valid(semver.coerce(releaseBranch))
const branchMaintenance = semver.valid(semver.coerce(branch))
const releaseMaintenance = semver.valid(semver.coerce(release.name))
const branchMaintenance = semver.valid(semver.coerce(branch.name))

if (releaseMaintenance && branchMaintenance) {
// don't keep branches of other major versions
Expand All @@ -104,96 +78,83 @@ export const getBranches = async (context: Context, config: BackmergeConfig, pla

// don't merge into older versions
if (semver.lt(branchMaintenance, releaseMaintenance)) {
deblog(`not backmerging into '${branch}' since semver version is before '${releaseBranch}'`)
deblog(`not backmerging into '${branch.name}' since semver version is before '${release.name}'`)
return false
}

// don't merge minor versions into next majors versions
if (semver.gte(branchMaintenance, nextMajor!)) {
deblog(`not backmerging into '${branch}' since semver major version is after '${releaseBranch}'`)
deblog(`not backmerging into '${branch.name}' since semver major version is after '${release.name}'`)
return false
}
}
return true
})
if (filteredBranches.length === 0) {
context.logger.log("No configured target is present in remote origin, no backmerge to be done.")
return []
}
deblog(`retrieved branches present in remote origin: '${JSON.stringify(filteredBranches)}'`)
return filteredBranches
deblog(`retrieved branches present in remote origin: '${JSON.stringify(mergeables)}'`)
return mergeables
}

/**
* executeBackmerge runs a backmerge from context.branch.name into all input branches.
* backmerge runs a backmerge from context.branch.name into all input branches.
*
* For that, it runs a fetch of input remote, then a checkout of the released branch (to ensure all commits are up to date)
* and then merge released branch into each branch from branches.
*
* If a merge fails, it tries to create a pull request.
*
* @param context input context with the logger, released branch, etc.
* @param config the semantic-release-backmerge plugin configuration.
* @param platformHandler the interface to handle current git platform API calls.
* @param branches slice of branches to be backmerged with released branch commits.
* @param handler the interface to handle current git platform API calls.
* @param mergeables slice of branches to be backmerged with released branch commits.
*
* @throws AggregateError of SemanticReleaseError(s) for each branch that couldn't be backmerged.
*/
export const executeBackmerge = async (context: Context, config: BackmergeConfig, platformHandler: PlatformHandler, branches: string[]) => {
const releaseBranch = context.branch.name

export const backmerge = async (context: Context, config: BackmergeConfig, handler: PlatformHandler, release: Branch, mergeables: Branch[]) => {
const url = parse(config.repositoryUrl)
const authRemote = authModificator(url, platformHandler.gitUser(), config.token)

try {
// ensure at any time and any moment that the fetch'ed remote url is the same as there
// https://github.com/semantic-release/git/blob/master/lib/prepare.js#L69
// it's to ensure that the commit done during @semantic-release/git is backmerged alongside the other commits
await fetch(authRemote, context.cwd, context.env)

// checkout to ensure released branch is up to date with last fetch'ed remote url
await checkout(releaseBranch, context.cwd, context.env)
} catch (error) {
throw new SemanticReleaseError(`Failed to fetch or checkout released branch '${releaseBranch}'.`, "EFETCHCHECKOUT", String(error))
}
const authRemote = authModificator(url, handler.gitUser(), config.token)

const errors: SemanticReleaseError[] = []
for (const branch of branches) { // keep await in loop since git actions aren't thread safe
for (const branch of mergeables) { // keep await in loop since git actions aren't thread safe
const templateData = {
from: releaseBranch,
from: release.name,
lastRelease: context.lastRelease,
nextRelease: context.nextRelease,
to: branch,
to: branch.name,
}

// try to merge with git the released branch into the current loop branch
try {
await merge(releaseBranch, branch, template(config.commit)(templateData), context.cwd, context.env)
await checkout(branch)
await merge(release.name, template(config.commit)(templateData), context.cwd, context.env)
if (config.dryRun) {
context.logger.log(`Running with --dry-run, push to '${branch}' will not update remote state.`)
context.logger.log(`Running with --dry-run, push to '${branch.name}' will not update remote state.`)
}
await push(authRemote, branch, config.dryRun, context.cwd, context.env)
await push(authRemote, branch.name, config.dryRun, context.cwd, context.env)
} catch (error) {
context.logger.error(`Failed to backmerge '${releaseBranch}' into '${branch}', opening pull request.`, error)
context.logger.error(`Failed to backmerge '${release.name}' into '${branch.name}', opening pull request.`, error)

if (config.dryRun) {
context.logger.log(`Running with --dry-run, created pull request would have been from '${releaseBranch}' into '${branch}'.`)
context.logger.log(`Running with --dry-run, created pull request would have been from '${release.name}' into '${branch.name}'.`)
continue
}

// in case of merge error with git, create a pull request with the platform handler
try {
const exists = await platformHandler.hasPull(url.owner, url.name, releaseBranch, branch)
const exists = await handler.hasPull(url.owner, url.name, release.name, branch.name)
if (exists) {
context.logger.log(`A pull request already exists between '${releaseBranch}' and '${branch}'. Not creating another.`)
context.logger.log(`A pull request already exists between '${release.name}' and '${branch.name}'. Not creating another.`)
continue
}

const title = template(config.title)(templateData)
await platformHandler.createPull(url.owner, url.name, {
await handler.createPull(url.owner, url.name, {
body: context.nextRelease.notes ?? "",
from: releaseBranch,
from: release.name,
title,
to: branch
to: branch.name
})
} catch (prerror) {
errors.push(new SemanticReleaseError(`Failed to create pull request from '${releaseBranch.name}' to '${branch.name}'.`, "EPULLREQUEST", String(prerror)))
errors.push(new SemanticReleaseError(`Failed to create pull request from '${release.name}' to '${branch.name}'.`, "EPULLREQUEST", String(prerror)))
}
}
}
Expand Down
43 changes: 30 additions & 13 deletions lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { execa } from "execa"

import debug from "debug"

/**
* Branch represents a simplified interface for a branch
* and its remote commit hash.
*/
export interface Branch {
hash: string
name: string
}

/**
* prefix needs to be semantic-release:
* @see https://github.com/semantic-release/semantic-release/blob/8940f32ccce455a01a4e32c101bb0f4a809ab00d/cli.js#L52
Expand Down Expand Up @@ -68,12 +77,19 @@ export const ls = async (remote: string, cwd?: string, env?: Record<string, stri
}
deblog("received stdout text from git ls-remote: %s", stdout)

const branches = stdout.
const branches: Branch[] = stdout.
split("\n").
map(branch => branch.split("\t")).
flat().
filter(branch => branch.startsWith("refs/heads/")).
map(branch => branch.replace("refs/heads/", ""))
filter(branch => branch.includes("refs/heads/")).
map(branch => branch.replace("refs/heads/", "")).
map(branch => {
const parts = branch.split("\t") // ls-remote lists elements with the form "<commit_hash>\t<branch_name>"
if (parts.length !== 2) {
deblog("retrieved invalid branch in git ls-remote: %s", branch)
return { hash: "", name: "" }
}
return { hash: parts[0], name: parts[1] }
}).
filter(branch => branch.name !== "" && branch.hash !== "") // filter unparseable branches
return [...new Set(branches)]
}

Expand All @@ -88,10 +104,14 @@ export const ls = async (remote: string, cwd?: string, env?: Record<string, stri
*
* @throws an error if the checkout cannot be done.
*/
export const checkout = async (branch: string, cwd?: string, env?: Record<string, string>) => {
deblog("executing git checkout command")
const { stderr } = await execa("git", ["checkout", "-B", branch], { cwd, env })
export const checkout = async (branch: Branch, cwd?: string, env?: Record<string, string>) => {
deblog("executing git checkout command with branch '%j'", branch)
const { stderr } = await execa("git", ["checkout", "-B", branch.name, branch.hash], { cwd, env })
if (stderr !== "") {
deblog("received stderr text from git checkout with branch '%j': %s", branch, stderr)
}
}

/**
* current returns the current commit where the current branch is at.
*
Expand Down Expand Up @@ -128,17 +148,14 @@ export const fetch = async (remote: string, cwd?: string, env?: Record<string, s
*
* If a merge commit must be done (by default --ff is used), then the merge commit is the input commit.
*
* @param from the branch to merge into 'to'.
* @param to the branch to merge changes from 'from'.
* @param from the branch to merge in the current one.
* @param commit the merge commit message if one is done.
* @param cwd the current directory.
* @param env all known environment variables.
*
* @throws an error if the merge fails (in case of conflicts, etc.).
*/
export const merge = async (from: string, to: string, commit: string, cwd?: string, env?: Record<string, string>) => {
await checkout(to)

export const merge = async (from: string, commit: string, cwd?: string, env?: Record<string, string>) => {
try {
deblog("executing git merge command with branch '%s'", from)
const { stderr } = await execa("git", ["merge", `${from}`, "--ff", "-m", commit], { cwd, env })
Expand Down
Loading

0 comments on commit 80c9ced

Please sign in to comment.