import os from 'node:os';
import {
runAsync, runSync, forceRunAsync
} from './run.js';
import Session from './session.js';
import {
shortSha, isGhAvailable, getEditor
} from './utils.js';
import { debuglog, isDebugVerbosity } from './verbosity.js';
const isWindows = process.platform === 'win32';
export const LINT_RESULTS = {
SKIPPED: 'skipped',
FAILED: 'failed',
SUCCESS: 'success'
};
export default class LandingSession extends Session {
constructor(cli, req, dir, {
prid, backport, lint, autorebase, fixupAll,
checkCI, oneCommitMax, ...argv
} = {}) {
super(cli, dir, prid, argv);
this.req = req;
this.backport = backport;
this.lint = lint;
this.autorebase = autorebase;
this.fixupAll = fixupAll;
this.oneCommitMax = oneCommitMax;
this.expectedCommitShas = [];
this.checkCI = !!checkCI;
}
get argv() {
const args = super.argv;
args.backport = this.backport;
args.lint = this.lint;
args.autorebase = this.autorebase;
args.fixupAll = this.fixupAll;
args.oneCommitMax = this.oneCommitMax;
return args;
}
async start(metadata) {
const { cli } = this;
this.startLanding();
this.expectedCommitShas =
metadata.data.commits.map(({ commit }) => commit.oid);
const status = metadata.status ? 'should be ready' : 'is not ready';
// NOTE(mmarchini): default answer is yes. If --yes is given, we need to be
// more careful though, and we change the default to the result of our
// metadata check.
const defaultAnswer = !cli.assumeYes ? true : metadata.status;
const shouldContinue = await cli.prompt(
`This PR ${status} to land, do you want to continue?`, { defaultAnswer });
if (!shouldContinue) {
await this.abort(false);
return process.exit(1);
}
this.saveMetadata(metadata);
this.startApplying();
return this.apply();
}
async abort(tryResetBranch = true) {
try {
const { cli } = this;
this.cleanFiles();
if (tryResetBranch) {
await this.tryResetBranch();
}
cli.ok(`Aborted \`git node land\` session in ${this.ncuDir}`);
} catch (ex) {
const { cli } = this;
cli.setExitCode(1);
cli.error(`Couldn't abort \`git node land\` session in ${this.ncuDir}`);
throw ex;
}
}
async downloadAndPatch() {
const {
cli, upstream, prid, expectedCommitShas, crossRepoPR, landedCommitSha
} = this;
if (crossRepoPR) {
const { owner, repo } = this;
cli.warn(`The configured remote "${upstream}" does not point to the ` +
'Pull Request repository.');
const isHeadAMergeCommit = () => {
try {
return runSync('git', ['rev-parse', 'FETCH_HEAD^2']).trim();
} catch {}
};
do {
cli.warn('Fetching commits from a different upstream might require user intervention.');
cli.info('Run the following command to fetch the commit(s):');
cli.info(`git fetch git@github.com:${owner}/${repo}.git refs/pull/${prid}/merge`);
const response = await cli.prompt('Should NCU run the command for you?',
{ defaultAnswer: false });
if (response) {
cli.startSpinner(`Downloading patch for ${prid}`);
await forceRunAsync('git',
['fetch', `git@github.com:${owner}/${repo}.git`, `refs/pull/${prid}/merge`],
{ ignoreFailure: false });
}
} while (!isHeadAMergeCommit());
} else if (landedCommitSha) {
// The PR already landed (e.g. a security fix released publicly), so
// its merge ref is gone - fetch the commits from the base branch.
cli.startSpinner(`Downloading landed commits for ${prid}`);
await forceRunAsync('git', ['fetch', upstream, landedCommitSha],
{ ignoreFailure: false });
} else {
cli.startSpinner(`Downloading patch for ${prid}`);
await runAsync('git', [
'fetch', upstream,
`refs/pull/${prid}/merge`]);
}
let base, head, rebaseHead;
if (landedCommitSha && !crossRepoPR) {
// FETCH_HEAD is the last commit of the PR as landed on the base branch.
// Walk back while the PR-URL trailer still points to this PR to find
// the base of the landed series (landing may have squashed commits).
const prUrlRe = new RegExp(`^PR-URL: \\S+/pull/${prid}$`, 'm');
let count = 0;
while (count < expectedCommitShas.length) {
const message = await runAsync('git',
['log', '-1', '--format=%B', `FETCH_HEAD~${count}`],
{ captureStdout: true });
if (!prUrlRe.test(message)) break;
count++;
}
if (count === 0) {
cli.error(`Landed commit ${landedCommitSha} does not have a ` +
`PR-URL trailer pointing to pull request ${prid}`);
process.exit(1);
}
[base, head, rebaseHead] = await runAsync('git',
['rev-parse', `FETCH_HEAD~${count}`, 'FETCH_HEAD', 'HEAD'],
{ captureStdout: 'lines' });
} else {
// We fetched the commit that would result if we used `git merge`.
// ^1 and ^2 refer to the PR base and the PR head, respectively.
[base, head, rebaseHead] = await runAsync('git',
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2', 'HEAD'],
{ captureStdout: 'lines' });
}
const commitShas = await runAsync('git',
['rev-list', `${base}..${head}`],
{ captureStdout: 'lines' });
cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`);
cli.separator();
if (landedCommitSha && !crossRepoPR) {
// The landed commits have different SHAs from the ones in the PR, so
// they cannot be compared - they were validated by their PR-URL
// trailers above.
cli.ok(`Using ${commitShas.length} commit(s) as landed for PR ${prid}`);
} else {
const mismatchedCommits = [
...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
.map((sha) => `Unexpected commit ${sha}`),
...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
.map((sha) => `Missing commit ${sha}`)
].join('\n');
if (mismatchedCommits.length > 0) {
cli.error(`Mismatched commits:\n${mismatchedCommits}`);
process.exit(1);
}
}
const commitInfo = { base, head, shas: commitShas, rebaseHead };
this.saveCommitInfo(commitInfo);
try {
await forceRunAsync('git',
['cherry-pick', '--allow-empty', ...this.gpgSign, `${base}..${head}`],
{ ignoreFailure: false });
} catch (ex) {
if (isDebugVerbosity()) {
debuglog('[LandingSession] Got error', ex);
}
cli.error('Failed to apply patches');
process.exit(1);
}
cli.ok('Patches applied');
return commitInfo;
}
getRebaseSuggestion(subjects) {
const { upstream, branch } = this;
let command = `git rebase ${upstream}/${branch} --no-keep-empty -i`;
command += ' -x "git node land --amend"';
const squashes = subjects.filter(
line => line.includes('fixup!') || line.includes('squash!') || line.includes('amend!'));
if (squashes.length !== 0) {
command += ' --autosquash';
}
if (this.gpgSign) {
command += ' ' + this.gpgSign.join(' ');
}
return command;
}
makeRebaseSuggestion(subjects) {
const suggestion = this.getRebaseSuggestion(subjects);
this.cli.log('Please run the following commands to complete landing\n\n' +
`$ ${suggestion}\n` +
'$ git node land --continue');
}
canAutomaticallyRebase(subjects) {
return subjects.every(line => !line.startsWith('squash!'));
}
async validateLint() {
// The linter is currently only run on non-Windows platforms.
if (os.platform() === 'win32') {
return LINT_RESULTS.SKIPPED;
}
if (!this.lint) {
return LINT_RESULTS.SKIPPED;
}
try {
await runAsync('make', ['lint']);
return LINT_RESULTS.SUCCESS;
} catch {
return LINT_RESULTS.FAILED;
}
}
async tryCompleteLanding(commitInfo) {
const { cli } = this;
const subjects = await runAsync('git',
['log', '--pretty=format:%s', `${commitInfo.base}..${commitInfo.head}`],
{ captureStdout: 'lines' });
if (commitInfo.shas.length === 1) {
const shouldAmend = await cli.prompt(
'There is only one commit in this PR.\n' +
'do you want to amend the commit message?');
if (!shouldAmend) {
return;
}
const canFinal = await this.amend();
if (!canFinal) {
return;
}
return this.final();
} else if (this.fixupAll) {
cli.log(`There are ${subjects.length} commits in the PR. ` +
'Attempting to fixup everything into first commit.');
await runAsync('git', ['reset', '--soft', `HEAD~${subjects.length - 1}`]);
await runAsync('git', ['commit', '--amend', '--no-edit', ...this.gpgSign]);
return await this.amend() && this.final();
} else if (this.autorebase && this.canAutomaticallyRebase(subjects)) {
// Run git rebase in interactive mode with autosquash but without editor
// so that it will perform everything automatically.
cli.log(`There are ${subjects.length} commits in the PR. ` +
'Attempting autorebase.');
const assumeYes = this.cli.assumeYes ? '--yes' : '';
const msgAmend = `-x "git node land --amend ${assumeYes}"`;
try {
await forceRunAsync('git',
['rebase', ...this.gpgSign, commitInfo.rebaseHead,
'--no-keep-empty', '-i', '--autosquash', msgAmend],
{
ignoreFailure: false,
spawnArgs: {
shell: true,
env: { ...process.env, GIT_SEQUENCE_EDITOR: ':' }
}
});
return this.final();
} catch {
await runAsync('git', ['rebase', '--abort']);
const count = subjects.length;
cli.log(`Couldn't rebase ${count} commits in the PR automatically`);
this.makeRebaseSuggestion(subjects);
}
} else {
this.makeRebaseSuggestion(subjects);
}
}
async apply() {
const { cli } = this;
// Bail if another landing session is currently in progress.
if (!this.isApplying()) {
cli.warn('Landing session already in progress - ' +
'to start a new one run `git node land --abort`');
return;
}
await this.tryResetBranch();
const commitInfo = await this.downloadAndPatch();
const cleanLint = await this.validateLint();
if (cleanLint === LINT_RESULTS.FAILED) {
const tryFixLint = await cli.prompt(
'Lint failed - try fixing with \'make lint-js-fix\'?');
if (tryFixLint) {
await runAsync('make', ['lint-js-fix']);
const fixed = await this.validateLint();
if (fixed === LINT_RESULTS.FAILED) {
cli.warn('Patch still contains lint errors. ' +
'Please fix manually before proceeding');
}
}
const correctedLint = await cli.prompt('Corrected all lint errors?');
if (correctedLint) {
await runAsync('git', ['add', '.']);
// Final message will be edited later - don't try to change it here.
await runAsync('git', ['commit', '--amend', '--no-edit', ...this.gpgSign]);
} else {
cli.info('Please fix lint errors and then run ' +
'`git node land --amend` followed by ' +
'`git node land --continue`.');
process.exit(1);
}
} else if (cleanLint === LINT_RESULTS.SUCCESS) {
cli.ok('Lint passed cleanly');
}
this.startAmending();
await this.tryCompleteLanding(commitInfo);
}
async generateAmendedMessage(original) {
const { cli, metadata } = this;
const BACKPORT_RE = /^BACKPORT-PR-URL\s*:\s*(\S+)$/i;
const PR_RE = /^PR-URL\s*:\s*(\S+)$/i;
const REVIEW_RE = /^Reviewed-By\s*:\s*(\S+)$/i;
const CVE_RE = /^CVE-ID\s*:\s*(\S+)$/im;
// git has very specific rules about what is a trailer and what is not.
// Instead of trying to implement those ourselves, let git parse the
// commit message and see if it outputs any trailers.
const interpretTrailers = commitMessage => runSync('git', [
'interpret-trailers', '--parse', '--no-divider'
], {
input: `${commitMessage}\n`
});
const originalTrailers = interpretTrailers(original);
const containCVETrailer = CVE_RE.test(originalTrailers);
const amended = original.trim().split('\n');
let keptTrailers = [];
if (originalTrailers) {
const stillInTrailers = () => {
const result = interpretTrailers(amended.join('\n'));
return result.length && originalTrailers.startsWith(result.trim());
};
// Remove all lines until we find an empty string, which should get of all
// trailers in most cases.
amended.splice(amended.lastIndexOf(''), Infinity);
for (let i = amended.length - 1; amended[i] === '' || stillInTrailers(); i--) {
// Remove last line until git no longer detects any trailers
amended.pop();
}
// Only keep existing trailers that we won't add ourselves
const trailersToFilterOut = ['BACKPORT-PR-URL', 'REVIEWED-BY'];
if (!this.backport) trailersToFilterOut.push('PR-URL');
keptTrailers = originalTrailers
.split('\n')
.map(trailer => {
const separatorIndex = trailer.indexOf(':');
return [trailer.slice(0, separatorIndex), trailer.slice(separatorIndex + 2)];
})
.slice(0, -1) // Remove the last line return entry
.filter(
([key]) => !trailersToFilterOut.includes(key.toUpperCase())
);
}
amended.push('', ...keptTrailers.map(([key, value]) => `${key}: ${value}`));
for (const line of metadata.trim().split('\n')) {
if (keptTrailers.some((trailer) => line.toUpperCase() === trailer.join(': ').toUpperCase())) {
cli.warn(`Found ${line}, skipping..`);
continue;
}
if (BACKPORT_RE.test(line)) {
const prIndex =
(amended.findIndex(datum => PR_RE.test(datum)) + 1) ||
amended.findIndex(datum => REVIEW_RE.test(datum));
if (prIndex !== -1) {
amended.splice(prIndex, 0, line);
continue;
}
}
amended.push(line);
}
if (!containCVETrailer && this.includeCVE) {
let cveID;
if (this.cveIds && this.cveIds.length > 0) {
cveID = this.cveIds.join(', ');
cli.ok(`Using CVE-ID from vulnerabilities.json: ${cveID}`);
} else {
// Fallback: check if the original commit has a PR-URL trailer
// and use it to look up CVE-IDs from the vulnerabilities map
if (this.vulnCveMap) {
const prUrlMatch = original.match(PR_RE);
if (prUrlMatch) {
const prUrl = prUrlMatch[1];
const cveIds = this.vulnCveMap.get(prUrl);
if (cveIds && cveIds.length > 0) {
cveID = cveIds.join(', ');
cli.ok(`Using CVE-ID from backport PR-URL (${prUrl}): ${cveID}`);
}
}
}
// Fall back to prompt if still not found
if (!cveID) {
cveID = await cli.prompt(
'Git found no CVE-ID trailer in the original commit message. ' +
'Please, provide the CVE-ID (e.g. CVE-2026-12345) or leave it empty',
{ questionType: 'input', defaultAnswer: '' }
);
}
}
// Some commits might not address a vulnerability, but it is necessary
// for the security release to happen.
if (cveID) {
amended.push('CVE-ID: ' + cveID);
}
}
return amended.join('\n');
}
async amend() {
const { cli } = this;
if (!this.readyToAmend()) {
cli.warn('Not yet ready to amend, run `git node land --abort`');
return;
}
this.startAmending();
const rev = this.getCurrentRev();
const original = runSync('git', [
'show', 'HEAD', '-s', '--format=%B'
]).trim();
const message = await this.generateAmendedMessage(original);
const messageFile = this.saveMessage(rev, message);
cli.separator('New Message');
cli.log(message.trim());
// `skipMessagePrompt` is only set by callers that already got a blanket
// confirmation; it is undefined on a regular landing session.
const takeMessage = this.skipMessagePrompt ||
await cli.prompt('Use this message?');
if (takeMessage) {
await runAsync('git', ['commit', '--amend', '-F', messageFile, ...this.gpgSign]);
return true;
}
const editor = await getEditor({ git: true });
if (editor) {
try {
await forceRunAsync(
editor,
[`"${messageFile}"`],
{ ignoreFailure: false, spawnArgs: { shell: true } }
);
await runAsync('git', ['commit', '--amend', '-F', messageFile, ...this.gpgSign]);
return true;
} catch {
cli.error('Failed to edit the message using the configured editor');
}
}
cli.log(`Please manually edit ${messageFile}, then run\n` +
`\`git commit --amend -F ${messageFile}\` ` +
'to finish amending the message');
process.exit(1); // make it work with git rebase -x
}
async final() {
const {
cli, owner, repo, upstream, branch, prid, oneCommitMax
} = this;
// Check that git rebase/am has been completed.
if (!this.readyToFinal()) {
cli.warn('Not yet ready to final');
cli.log('A git rebase/am is in progress.' +
' Please complete it before running git node land --final');
return;
}
const stray = this.getStrayCommits();
if (stray.length > 1) {
const forceLand = await cli.prompt(
'There is more than one commit in the PR. ' +
'Do you still want to land it?',
{ defaultAnswer: !oneCommitMax });
if (!forceLand) {
cli.info(
'GITHUB_ACTION' in process.env
? 'Add `commit-queue-squash` label to land the PR as one commit, ' +
'or `commit-queue-rebase` to land as separate commits.'
: 'Use --fixupAll option, squash the PR manually or land the PR ' +
'from the command line.'
);
process.exit(1);
}
}
const strayVerbose = this.getStrayCommits(true);
const validateCommand = new URL(
'../node_modules/.bin/core-validate-commit' + (isWindows ? '.cmd' : ''),
import.meta.url
);
try {
await forceRunAsync(validateCommand, stray, { ignoreFailure: false });
} catch (e) {
let forceLand = false;
if (e.code === 1) {
forceLand = await cli.prompt(
'The commit did not pass the validation. ' +
'Do you still want to land it?',
{ defaultAnswer: false });
}
if (!forceLand) {
cli.info('Please fix the commit message and try again.');
cli.log('Please manually ammend the commit message, by running\n' +
'`git commit --amend`\n' +
'Once commit message is fixed, finish the landing command running\n' +
'`git node land --continue`');
process.exit(1);
}
}
cli.separator();
cli.log('The following commits are landable on ' +
`${upstream}/${branch}`);
cli.log(`- ${strayVerbose.join('\n- ')}`);
cli.separator();
let willBeLanded = shortSha(stray[stray.length - 1]);
if (stray.length > 1) {
const head = shortSha(this.getUpstreamHead());
willBeLanded = `${head}...${willBeLanded}`;
}
cli.startSpinner('Removing temporary files');
this.cleanFiles();
cli.stopSpinner('Temporary files removed.');
if (this.crossRepoPR) {
cli.warn(`The configured remote "${upstream}" does not point to the ` +
'Pull Request repository.');
cli.info(`${willBeLanded} should likely not be pushed to "${upstream}"`);
return;
}
cli.log('To finish landing:');
cli.log('1. Run: ');
cli.log(` git push ${upstream} ${branch}`);
const url = `https://github.com/${owner}/${repo}/pull/${prid}`;
cli.log(`2. Post "Landed in ${willBeLanded}" in ${url}`);
if (isGhAvailable()) {
cli.log(` gh pr comment ${url} --body "Landed in ${willBeLanded}"`);
cli.log(` gh pr close ${url}`);
}
}
async continue() {
const { cli } = this;
if (this.readyToFinal()) {
cli.log('Running `final`..');
return this.final();
}
if (this.readyToAmend()) {
cli.log('Running `amend`..');
return this.amend();
}
if (this.isApplying()) {
// We're still resolving conflicts.
if (this.cherryPickInProgress()) {
cli.log('Looks like you are resolving a `git cherry-pick` conflict');
cli.log('Please run `git status` for help');
} else {
// Conflicts has been resolved - amend.
this.startAmending();
return this.tryCompleteLanding(this.commitInfo);
}
return;
}
if (this.hasStarted()) {
cli.log('Running `apply`..');
return this.apply();
}
cli.log(
'Please run `git node land <PRID> to start a landing session`');
}
async status() {
// TODO
}
async warnForWrongBranch() {
if (super.warnForWrongBranch()) {
return true;
}
const rev = this.getCurrentBranch();
const { repository: { defaultBranchRef } } = await this.req.gql(
'DefaultBranchRef',
{ owner: this.owner, repo: this.repo });
if ((rev === 'master' || rev === 'main') && defaultBranchRef.name !== rev) {
this.cli.warn(`You are running git-node-land on \`${rev}\`,` +
` but the default branch is \`${defaultBranchRef.name}\`.`);
this.cli.setExitCode(1);
return true;
}
}
}