1+ import os from 'node:os' ;
12import path from 'node:path' ;
23import { getMetadata } from '../components/metadata.js' ;
34
45import {
5- runAsync , runSync
6+ runAsync , runSync , forceRunAsync
67} from './run.js' ;
8+ import { writeFile } from './file.js' ;
9+ import {
10+ shortSha , getEditor
11+ } from './utils.js' ;
712import { getNcuDir } from './config.js' ;
8- import LandingSession , { LINT_RESULTS } from './landing_session.js' ;
913
10- export default class CherryPick {
14+ const LINT_RESULTS = {
15+ SKIPPED : 'skipped' ,
16+ FAILED : 'failed' ,
17+ SUCCESS : 'success'
18+ } ;
19+
20+ export default class CheckPick {
1121 constructor ( prid , dir , cli , {
1222 owner,
1323 repo,
@@ -36,6 +46,11 @@ export default class CherryPick {
3646 return this . options . lint ;
3747 }
3848
49+ getUpstreamHead ( ) {
50+ const { upstream, branch } = this ;
51+ return runSync ( 'git' , [ 'rev-parse' , `${ upstream } /${ branch } ` ] ) . trim ( ) ;
52+ }
53+
3954 getCurrentRev ( ) {
4055 return runSync ( 'git' , [ 'rev-parse' , 'HEAD' ] ) . trim ( ) ;
4156 }
@@ -58,6 +73,16 @@ export default class CherryPick {
5873 return path . resolve ( this . ncuDir , `${ this . prid } ` ) ;
5974 }
6075
76+ getMessagePath ( rev ) {
77+ return path . resolve ( this . pullDir , `${ shortSha ( rev ) } .COMMIT_EDITMSG` ) ;
78+ }
79+
80+ saveMessage ( rev , message ) {
81+ const file = this . getMessagePath ( rev ) ;
82+ writeFile ( file , message ) ;
83+ return file ;
84+ }
85+
6186 async start ( ) {
6287 const { cli } = this ;
6388
@@ -66,7 +91,7 @@ export default class CherryPick {
6691 owner : this . owner ,
6792 repo : this . repo
6893 } , false , cli ) ;
69- this . expectedCommitShas =
94+ const expectedCommitShas =
7095 metadata . data . commits . map ( ( { commit } ) => commit . oid ) ;
7196
7297 const amend = await cli . prompt (
@@ -79,7 +104,7 @@ export default class CherryPick {
79104 }
80105
81106 try {
82- const commitInfo = await this . downloadAndPatch ( ) ;
107+ const commitInfo = await this . downloadAndPatch ( expectedCommitShas ) ;
83108 const cleanLint = await this . validateLint ( ) ;
84109 if ( cleanLint === LINT_RESULTS . FAILED ) {
85110 cli . error ( 'Patch still contains lint errors. ' +
@@ -95,6 +120,68 @@ export default class CherryPick {
95120 }
96121 }
97122
123+ async downloadAndPatch ( expectedCommitShas ) {
124+ const { cli, repo, owner, prid } = this ;
125+
126+ cli . startSpinner ( `Downloading patch for ${ prid } ` ) ;
127+ // fetch via ssh to handle private repo
128+ await runAsync ( 'git' , [
129+ 'fetch' , `git@github.com:${ owner } /${ repo } .git` ,
130+ `refs/pull/${ prid } /merge` ] ) ;
131+ // We fetched the commit that would result if we used `git merge`.
132+ // ^1 and ^2 refer to the PR base and the PR head, respectively.
133+ const [ base , head ] = await runAsync ( 'git' ,
134+ [ 'rev-parse' , 'FETCH_HEAD^1' , 'FETCH_HEAD^2' ] ,
135+ { captureStdout : 'lines' } ) ;
136+ const commitShas = await runAsync ( 'git' ,
137+ [ 'rev-list' , `${ base } ..${ head } ` ] ,
138+ { captureStdout : 'lines' } ) ;
139+ cli . stopSpinner ( `Fetched commits as ${ shortSha ( base ) } ..${ shortSha ( head ) } ` ) ;
140+ cli . separator ( ) ;
141+
142+ const mismatchedCommits = [
143+ ...commitShas . filter ( ( sha ) => ! expectedCommitShas . includes ( sha ) )
144+ . map ( ( sha ) => `Unexpected commit ${ sha } ` ) ,
145+ ...expectedCommitShas . filter ( ( sha ) => ! commitShas . includes ( sha ) )
146+ . map ( ( sha ) => `Missing commit ${ sha } ` )
147+ ] . join ( '\n' ) ;
148+ if ( mismatchedCommits . length > 0 ) {
149+ throw new Error ( `Mismatched commits:\n${ mismatchedCommits } ` ) ;
150+ }
151+
152+ const commitInfo = { base, head, shas : commitShas } ;
153+
154+ try {
155+ await forceRunAsync ( 'git' , [ 'cherry-pick' , `${ base } ..${ head } ` ] , {
156+ ignoreFailure : false
157+ } ) ;
158+ } catch ( ex ) {
159+ await forceRunAsync ( 'git' , [ 'cherry-pick' , '--abort' ] ) ;
160+ throw new Error ( 'Failed to apply patches' ) ;
161+ }
162+
163+ cli . ok ( 'Patches applied' ) ;
164+ return commitInfo ;
165+ }
166+
167+ async validateLint ( ) {
168+ // The linter is currently only run on non-Windows platforms.
169+ if ( os . platform ( ) === 'win32' ) {
170+ return LINT_RESULTS . SKIPPED ;
171+ }
172+
173+ if ( ! this . lint ) {
174+ return LINT_RESULTS . SKIPPED ;
175+ }
176+
177+ try {
178+ await runAsync ( 'make' , [ 'lint' ] ) ;
179+ return LINT_RESULTS . SUCCESS ;
180+ } catch {
181+ return LINT_RESULTS . FAILED ;
182+ }
183+ }
184+
98185 async amend ( metadata , commitInfo ) {
99186 const { cli } = this ;
100187 const subjects = await runAsync ( 'git' ,
@@ -116,23 +203,102 @@ export default class CherryPick {
116203 await runAsync ( 'git' , [ 'commit' , '--amend' , '--no-edit' ] ) ;
117204 }
118205
119- return LandingSession . prototype . amend . call ( this , metadata ) ;
206+ return this . _amend ( metadata ) ;
120207 }
121208
122- readyToAmend ( ) {
123- return true ;
124- }
209+ async _amend ( metadataStr ) {
210+ const { cli } = this ;
125211
126- startAmending ( ) {
127- // No-op
128- }
212+ const rev = this . getCurrentRev ( ) ;
213+ const original = runSync ( 'git' , [
214+ 'show' , 'HEAD' , '-s' , '--format=%B'
215+ ] ) . trim ( ) ;
216+ // git has very specific rules about what is a trailer and what is not.
217+ // Instead of trying to implement those ourselves, let git parse the
218+ // original commit message and see if it outputs any trailers.
219+ const originalHasTrailers = runSync ( 'git' , [
220+ 'interpret-trailers' , '--parse' , '--no-divider'
221+ ] , {
222+ input : `${ original } \n`
223+ } ) . trim ( ) . length !== 0 ;
224+ const metadata = metadataStr . trim ( ) . split ( '\n' ) ;
225+ const amended = original . split ( '\n' ) ;
226+
227+ // If the original commit message already contains trailers (such as
228+ // "Co-authored-by"), we simply add our own metadata after those. Otherwise,
229+ // we have to add an empty line so that git recognizes our own metadata as
230+ // trailers in the amended commit message.
231+ if ( ! originalHasTrailers ) {
232+ amended . push ( '' ) ;
233+ }
234+
235+ const BACKPORT_RE = / B A C K P O R T - P R - U R L \s * : \s * ( \S + ) / i;
236+ const PR_RE = / P R - U R L \s * : \s * ( \S + ) / i;
237+ const REVIEW_RE = / R e v i e w e d - B y \s * : \s * ( \S + ) / i;
238+ const CVE_RE = / C V E - I D \s * : \s * ( \S + ) / i;
129239
130- saveCommitInfo ( ) {
131- // No-op
240+ let containCVETrailer = false ;
241+ for ( const line of metadata ) {
242+ if ( line . length !== 0 && original . includes ( line ) ) {
243+ if ( line . match ( CVE_RE ) ) {
244+ containCVETrailer = true ;
245+ }
246+ if ( originalHasTrailers ) {
247+ cli . warn ( `Found ${ line } , skipping..` ) ;
248+ } else {
249+ throw new Error (
250+ 'Git found no trailers in the original commit message, ' +
251+ `but '${ line } ' is present and should be a trailer.` ) ;
252+ }
253+ } else {
254+ if ( line . match ( BACKPORT_RE ) ) {
255+ let prIndex = amended . findIndex ( datum => datum . match ( PR_RE ) ) ;
256+ if ( prIndex === - 1 ) {
257+ prIndex = amended . findIndex ( datum => datum . match ( REVIEW_RE ) ) - 1 ;
258+ }
259+ amended . splice ( prIndex + 1 , 0 , line ) ;
260+ } else {
261+ amended . push ( line ) ;
262+ }
263+ }
264+ }
265+
266+ if ( ! containCVETrailer && this . includeCVE ) {
267+ const cveID = await cli . prompt (
268+ 'Git found no CVE-ID trailer in the original commit message. ' +
269+ 'Please, provide the CVE-ID' ,
270+ { questionType : 'input' , defaultAnswer : 'CVE-2023-XXXXX' }
271+ ) ;
272+ amended . push ( 'CVE-ID: ' + cveID ) ;
273+ }
274+
275+ const message = amended . join ( '\n' ) ;
276+ const messageFile = this . saveMessage ( rev , message ) ;
277+ cli . separator ( 'New Message' ) ;
278+ cli . log ( message . trim ( ) ) ;
279+ const takeMessage = await cli . prompt ( 'Use this message?' ) ;
280+ if ( takeMessage ) {
281+ await runAsync ( 'git' , [ 'commit' , '--amend' , '-F' , messageFile ] ) ;
282+ return true ;
283+ }
284+
285+ const editor = await getEditor ( { git : true } ) ;
286+ if ( editor ) {
287+ try {
288+ await forceRunAsync (
289+ editor ,
290+ [ `"${ messageFile } "` ] ,
291+ { ignoreFailure : false , spawnArgs : { shell : true } }
292+ ) ;
293+ await runAsync ( 'git' , [ 'commit' , '--amend' , '-F' , messageFile ] ) ;
294+ return true ;
295+ } catch {
296+ cli . warn ( `Please manually edit ${ messageFile } , then run\n` +
297+ `\`git commit --amend -F ${ messageFile } \` ` +
298+ 'to finish amending the message' ) ;
299+ throw new Error (
300+ 'Failed to edit the message using the configured editor' ) ;
301+ }
302+ }
132303 }
133304}
134-
135- CherryPick . prototype . downloadAndPatch = LandingSession . prototype . downloadAndPatch ;
136- CherryPick . prototype . validateLint = LandingSession . prototype . validateLint ;
137- CherryPick . prototype . getMessagePath = LandingSession . prototype . getMessagePath ;
138- CherryPick . prototype . saveMessage = LandingSession . prototype . saveMessage ;
0 commit comments