1
+ import os from 'node:os' ;
1
2
import path from 'node:path' ;
2
3
import { getMetadata } from '../components/metadata.js' ;
3
4
4
5
import {
5
- runAsync , runSync
6
+ runAsync , runSync , forceRunAsync
6
7
} from './run.js' ;
8
+ import { writeFile } from './file.js' ;
9
+ import {
10
+ shortSha , getEditor
11
+ } from './utils.js' ;
7
12
import { getNcuDir } from './config.js' ;
8
- import LandingSession , { LINT_RESULTS } from './landing_session.js' ;
9
13
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 {
11
21
constructor ( prid , dir , cli , {
12
22
owner,
13
23
repo,
@@ -36,6 +46,11 @@ export default class CherryPick {
36
46
return this . options . lint ;
37
47
}
38
48
49
+ getUpstreamHead ( ) {
50
+ const { upstream, branch } = this ;
51
+ return runSync ( 'git' , [ 'rev-parse' , `${ upstream } /${ branch } ` ] ) . trim ( ) ;
52
+ }
53
+
39
54
getCurrentRev ( ) {
40
55
return runSync ( 'git' , [ 'rev-parse' , 'HEAD' ] ) . trim ( ) ;
41
56
}
@@ -58,6 +73,16 @@ export default class CherryPick {
58
73
return path . resolve ( this . ncuDir , `${ this . prid } ` ) ;
59
74
}
60
75
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
+
61
86
async start ( ) {
62
87
const { cli } = this ;
63
88
@@ -66,7 +91,7 @@ export default class CherryPick {
66
91
owner : this . owner ,
67
92
repo : this . repo
68
93
} , false , cli ) ;
69
- this . expectedCommitShas =
94
+ const expectedCommitShas =
70
95
metadata . data . commits . map ( ( { commit } ) => commit . oid ) ;
71
96
72
97
const amend = await cli . prompt (
@@ -79,7 +104,7 @@ export default class CherryPick {
79
104
}
80
105
81
106
try {
82
- const commitInfo = await this . downloadAndPatch ( ) ;
107
+ const commitInfo = await this . downloadAndPatch ( expectedCommitShas ) ;
83
108
const cleanLint = await this . validateLint ( ) ;
84
109
if ( cleanLint === LINT_RESULTS . FAILED ) {
85
110
cli . error ( 'Patch still contains lint errors. ' +
@@ -95,6 +120,68 @@ export default class CherryPick {
95
120
}
96
121
}
97
122
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' , `[email protected] :${ 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
+
98
185
async amend ( metadata , commitInfo ) {
99
186
const { cli } = this ;
100
187
const subjects = await runAsync ( 'git' ,
@@ -116,23 +203,102 @@ export default class CherryPick {
116
203
await runAsync ( 'git' , [ 'commit' , '--amend' , '--no-edit' ] ) ;
117
204
}
118
205
119
- return LandingSession . prototype . amend . call ( this , metadata ) ;
206
+ return this . _amend ( metadata ) ;
120
207
}
121
208
122
- readyToAmend ( ) {
123
- return true ;
124
- }
209
+ async _amend ( metadataStr ) {
210
+ const { cli } = this ;
125
211
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;
129
239
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
+ }
132
303
}
133
304
}
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