@@ -18,6 +18,7 @@ type CherryPickOptions struct {
1818 Releases []string
1919 DryRun bool
2020 Yes bool
21+ NoVerify bool
2122}
2223
2324// NewCherryPickCommand creates a new cherry-pick command
@@ -50,6 +51,7 @@ Example usage:
5051 cmd .Flags ().StringSliceVar (& opts .Releases , "release" , []string {}, "Release version(s) to cherry-pick to (e.g., 1.0, v1.1). 'v' prefix is optional. Can be specified multiple times." )
5152 cmd .Flags ().BoolVar (& opts .DryRun , "dry-run" , false , "Perform all local operations but skip pushing to remote and creating PRs" )
5253 cmd .Flags ().BoolVar (& opts .Yes , "yes" , false , "Skip confirmation prompts and automatically proceed" )
54+ cmd .Flags ().BoolVar (& opts .NoVerify , "no-verify" , false , "Skip pre-commit and commit-msg hooks for cherry-pick and push" )
5355
5456 return cmd
5557}
@@ -75,6 +77,17 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
7577 }
7678 log .Debugf ("Original branch: %s" , originalBranch )
7779
80+ // Stash any uncommitted changes before switching branches
81+ stashResult , err := git .StashChanges ()
82+ if err != nil {
83+ log .Fatalf ("Failed to stash changes: %v" , err )
84+ }
85+
86+ // Fetch commits from remote before cherry-picking
87+ if err := git .FetchCommits (commitSHAs ); err != nil {
88+ log .Warnf ("Failed to fetch commits: %v" , err )
89+ }
90+
7891 // Get the short SHA(s) for branch naming
7992 var branchSuffix string
8093 if len (commitSHAs ) == 1 {
@@ -108,13 +121,15 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
108121 // Find the nearest stable tag using the first commit
109122 version , err := findNearestStableTag (commitSHAs [0 ])
110123 if err != nil {
124+ git .RestoreStash (stashResult )
111125 log .Fatalf ("Failed to find nearest stable tag: %v" , err )
112126 }
113127
114128 // Prompt user for confirmation
115129 if ! opts .Yes {
116130 if ! prompt .Confirm (fmt .Sprintf ("Auto-detected release version: %s. Continue? (yes/no): " , version )) {
117131 log .Info ("If you want to cherry-pick to a different release, use the --release flag. Exiting..." )
132+ git .RestoreStash (stashResult )
118133 return
119134 }
120135 } else {
@@ -124,19 +139,28 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
124139 releases = []string {version }
125140 }
126141
127- // Get commit message(s) for PR title
142+ // Get commit messages for PR title and body
143+ commitMessages := make ([]string , len (commitSHAs ))
144+ for i , sha := range commitSHAs {
145+ msg , err := git .GetCommitMessage (sha )
146+ if err != nil {
147+ log .Warnf ("Failed to get commit message for %s: %v" , sha , err )
148+ commitMessages [i ] = ""
149+ } else {
150+ commitMessages [i ] = msg
151+ }
152+ }
153+
128154 var prTitle string
129155 if len (commitSHAs ) == 1 {
130- commitMsg , err := git . GetCommitMessage ( commitSHAs [0 ])
131- if err != nil {
132- log . Warnf ( "Failed to get commit message, using default title: %v" , err )
156+ if commitMessages [0 ] != "" {
157+ prTitle = commitMessages [ 0 ]
158+ } else {
133159 shortSHA := commitSHAs [0 ]
134160 if len (shortSHA ) > 8 {
135161 shortSHA = shortSHA [:8 ]
136162 }
137163 prTitle = fmt .Sprintf ("chore(hotfix): cherry-pick %s" , shortSHA )
138- } else {
139- prTitle = commitMsg
140164 }
141165 } else {
142166 // For multiple commits, use a generic title
@@ -148,11 +172,19 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
148172 for _ , release := range releases {
149173 log .Infof ("Processing release %s" , release )
150174 prTitleWithRelease := fmt .Sprintf ("%s to release %s" , prTitle , release )
151- prURL , err := cherryPickToRelease (commitSHAs , branchSuffix , release , prTitleWithRelease , opts .DryRun )
175+ prURL , err := cherryPickToRelease (commitSHAs , commitMessages , branchSuffix , release , prTitleWithRelease , opts .DryRun , opts . NoVerify )
152176 if err != nil {
153- // Switch back to original branch before exiting on error
154- if switchErr := git .RunCommand ("switch" , "--quiet" , originalBranch ); switchErr != nil {
155- log .Warnf ("Failed to switch back to original branch: %v" , switchErr )
177+ // Don't try to switch back if there's a merge conflict - git won't allow it
178+ if strings .Contains (err .Error (), "merge conflict" ) {
179+ if stashResult .Stashed {
180+ log .Warn ("Your uncommitted changes are still stashed." )
181+ log .Infof ("After resolving the conflict and returning to %s, run: git stash pop" , originalBranch )
182+ }
183+ } else {
184+ if switchErr := git .RunCommand ("switch" , "--quiet" , originalBranch ); switchErr != nil {
185+ log .Warnf ("Failed to switch back to original branch: %v" , switchErr )
186+ }
187+ git .RestoreStash (stashResult )
156188 }
157189 log .Fatalf ("Failed to cherry-pick to release %s: %v" , release , err )
158190 }
@@ -167,14 +199,17 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
167199 log .Warnf ("Failed to switch back to original branch: %v" , err )
168200 }
169201
202+ // Restore stashed changes now that we're back on the original branch
203+ git .RestoreStash (stashResult )
204+
170205 // Print all PR URLs
171206 for i , prURL := range prURLs {
172207 log .Infof ("PR %d: %s" , i + 1 , prURL )
173208 }
174209}
175210
176211// cherryPickToRelease cherry-picks one or more commits to a specific release branch
177- func cherryPickToRelease (commitSHAs []string , branchSuffix , version , prTitle string , dryRun bool ) (string , error ) {
212+ func cherryPickToRelease (commitSHAs , commitMessages []string , branchSuffix , version , prTitle string , dryRun , noVerify bool ) (string , error ) {
178213 releaseBranch := fmt .Sprintf ("release/%s" , version )
179214 hotfixBranch := fmt .Sprintf ("hotfix/%s-%s" , branchSuffix , version )
180215
@@ -184,23 +219,43 @@ func cherryPickToRelease(commitSHAs []string, branchSuffix, version, prTitle str
184219 return "" , fmt .Errorf ("failed to fetch release branch %s: %w" , releaseBranch , err )
185220 }
186221
187- // Create the hotfix branch from the release branch
188- log .Infof ("Creating hotfix branch: %s" , hotfixBranch )
189- if err := git .RunCommand ("checkout" , "--quiet" , "-b" , hotfixBranch , fmt .Sprintf ("origin/%s" , releaseBranch )); err != nil {
190- return "" , fmt .Errorf ("failed to create hotfix branch: %w" , err )
191- }
222+ // Check if hotfix branch already exists
223+ branchExists := git .BranchExists (hotfixBranch )
224+ if branchExists {
225+ log .Infof ("Hotfix branch %s already exists, switching" , hotfixBranch )
226+ if err := git .RunCommand ("switch" , "--quiet" , hotfixBranch ); err != nil {
227+ return "" , fmt .Errorf ("failed to checkout existing hotfix branch: %w" , err )
228+ }
192229
193- // Cherry-pick the commits
194- if len (commitSHAs ) == 1 {
195- log .Infof ("Cherry-picking commit: %s" , commitSHAs [0 ])
230+ // Check which commits need to be cherry-picked
231+ commitsToCherry := []string {}
232+ for _ , sha := range commitSHAs {
233+ if git .CommitExistsOnBranch (sha , hotfixBranch ) {
234+ log .Infof ("Commit %s already exists on branch %s, skipping" , sha , hotfixBranch )
235+ } else {
236+ commitsToCherry = append (commitsToCherry , sha )
237+ }
238+ }
239+
240+ if len (commitsToCherry ) == 0 {
241+ log .Infof ("All commits already exist on branch %s" , hotfixBranch )
242+ } else {
243+ // Cherry-pick only the missing commits
244+ if err := performCherryPick (commitsToCherry ); err != nil {
245+ return "" , err
246+ }
247+ }
196248 } else {
197- log .Infof ("Cherry-picking %d commits: %s" , len (commitSHAs ), strings .Join (commitSHAs , " " ))
198- }
249+ // Create the hotfix branch from the release branch
250+ log .Infof ("Creating hotfix branch: %s" , hotfixBranch )
251+ if err := git .RunCommand ("checkout" , "--quiet" , "-b" , hotfixBranch , fmt .Sprintf ("origin/%s" , releaseBranch )); err != nil {
252+ return "" , fmt .Errorf ("failed to create hotfix branch: %w" , err )
253+ }
199254
200- // Build git cherry -pick command with all commits
201- cherryPickArgs := append ([] string { "cherry-pick" }, commitSHAs ... )
202- if err := git . RunCommand ( cherryPickArgs ... ); err != nil {
203- return "" , fmt . Errorf ( "failed to cherry-pick commits: %w" , err )
255+ // Cherry -pick all commits
256+ if err := performCherryPick ( commitSHAs ); err != nil {
257+ return "" , err
258+ }
204259 }
205260
206261 if dryRun {
@@ -211,13 +266,17 @@ func cherryPickToRelease(commitSHAs []string, branchSuffix, version, prTitle str
211266
212267 // Push the hotfix branch
213268 log .Infof ("Pushing hotfix branch: %s" , hotfixBranch )
214- if err := git .RunCommand ("push" , "--quiet" , "-u" , "origin" , hotfixBranch ); err != nil {
269+ pushArgs := []string {"push" , "-u" , "origin" , hotfixBranch }
270+ if noVerify {
271+ pushArgs = []string {"push" , "--no-verify" , "-u" , "origin" , hotfixBranch }
272+ }
273+ if err := git .RunCommandVerboseOnError (pushArgs ... ); err != nil {
215274 return "" , fmt .Errorf ("failed to push hotfix branch: %w" , err )
216275 }
217276
218277 // Create PR using GitHub CLI
219278 log .Info ("Creating PR..." )
220- prURL , err := createCherryPickPR (hotfixBranch , releaseBranch , prTitle , commitSHAs )
279+ prURL , err := createCherryPickPR (hotfixBranch , releaseBranch , prTitle , commitSHAs , commitMessages )
221280 if err != nil {
222281 return "" , fmt .Errorf ("failed to create PR: %w" , err )
223282 }
@@ -226,6 +285,54 @@ func cherryPickToRelease(commitSHAs []string, branchSuffix, version, prTitle str
226285 return prURL , nil
227286}
228287
288+ // performCherryPick cherry-picks the given commits
289+ func performCherryPick (commitSHAs []string ) error {
290+ if len (commitSHAs ) == 0 {
291+ return nil
292+ }
293+
294+ if len (commitSHAs ) == 1 {
295+ log .Infof ("Cherry-picking commit: %s" , commitSHAs [0 ])
296+ } else {
297+ log .Infof ("Cherry-picking %d commits: %s" , len (commitSHAs ), strings .Join (commitSHAs , " " ))
298+ }
299+
300+ // Build git cherry-pick command with all commits
301+ // Note: git cherry-pick does not support --no-verify; hooks run during cherry-pick
302+ cherryPickArgs := []string {"cherry-pick" }
303+ cherryPickArgs = append (cherryPickArgs , commitSHAs ... )
304+
305+ if err := git .RunCommandVerboseOnError (cherryPickArgs ... ); err != nil {
306+ // Check if this is a merge conflict
307+ if git .HasMergeConflict () {
308+ log .Error ("Cherry-pick failed due to merge conflict!" )
309+ log .Info ("To resolve:" )
310+ log .Info (" 1. Fix the conflicts in the affected files" )
311+ log .Info (" 2. Stage the resolved files: git add <files>" )
312+ log .Info (" 3. Continue the cherry-pick: git cherry-pick --continue" )
313+ log .Info (" 4. Re-run this command to continue with the remaining steps" )
314+ return fmt .Errorf ("merge conflict during cherry-pick" )
315+ }
316+ // Check if cherry-pick is empty (commit already applied with different SHA)
317+ // Only skip if there are no staged changes - if user resolved conflicts and staged,
318+ // they should run `git cherry-pick --continue` instead
319+ if git .IsCherryPickInProgress () {
320+ if git .HasStagedChanges () {
321+ log .Error ("Cherry-pick in progress with staged changes." )
322+ log .Info ("It looks like you resolved conflicts. Run: git cherry-pick --continue" )
323+ return fmt .Errorf ("cherry-pick in progress with staged changes" )
324+ }
325+ log .Info ("Cherry-pick is empty (changes already applied), skipping..." )
326+ if skipErr := git .RunCommand ("cherry-pick" , "--skip" ); skipErr != nil {
327+ return fmt .Errorf ("failed to skip empty cherry-pick: %w" , skipErr )
328+ }
329+ return nil
330+ }
331+ return fmt .Errorf ("failed to cherry-pick commits: %w" , err )
332+ }
333+ return nil
334+ }
335+
229336// normalizeVersion ensures the version has a 'v' prefix
230337func normalizeVersion (version string ) string {
231338 if ! strings .HasPrefix (version , "v" ) {
@@ -234,6 +341,13 @@ func normalizeVersion(version string) string {
234341 return version
235342}
236343
344+ // extractPRNumbers extracts GitHub PR numbers (e.g., #1234) from a commit message
345+ func extractPRNumbers (commitMsg string ) []string {
346+ re := regexp .MustCompile (`#(\d+)` )
347+ matches := re .FindAllString (commitMsg , - 1 )
348+ return matches
349+ }
350+
237351// findNearestStableTag finds the nearest tag matching v*.*.* pattern and returns major.minor
238352func findNearestStableTag (commitSHA string ) (string , error ) {
239353 // Get tags that are ancestors of the commit, sorted by version
@@ -257,17 +371,43 @@ func findNearestStableTag(commitSHA string) (string, error) {
257371}
258372
259373// createCherryPickPR creates a pull request for cherry-picks using the GitHub CLI
260- func createCherryPickPR (headBranch , baseBranch , title string , commitSHAs []string ) (string , error ) {
374+ func createCherryPickPR (headBranch , baseBranch , title string , commitSHAs , commitMessages []string ) (string , error ) {
261375 var body string
376+
377+ // Collect all original PR numbers for the summary
378+ allPRNumbers := []string {}
379+ for _ , msg := range commitMessages {
380+ if msg != "" {
381+ prNumbers := extractPRNumbers (msg )
382+ allPRNumbers = append (allPRNumbers , prNumbers ... )
383+ }
384+ }
385+
262386 if len (commitSHAs ) == 1 {
263387 body = fmt .Sprintf ("Cherry-pick of commit %s to %s branch." , commitSHAs [0 ], baseBranch )
388+ if len (allPRNumbers ) > 0 {
389+ body += fmt .Sprintf ("\n \n Original PR: %s" , strings .Join (allPRNumbers , ", " ))
390+ }
264391 } else {
265392 body = fmt .Sprintf ("Cherry-pick of %d commits to %s branch:\n \n " , len (commitSHAs ), baseBranch )
266- for _ , sha := range commitSHAs {
267- body += fmt .Sprintf ("- %s\n " , sha )
393+ for i , sha := range commitSHAs {
394+ // Include original PR reference if present
395+ var prRef string
396+ if i < len (commitMessages ) && commitMessages [i ] != "" {
397+ prNumbers := extractPRNumbers (commitMessages [i ])
398+ if len (prNumbers ) > 0 {
399+ prRef = fmt .Sprintf (" (Original: %s)" , strings .Join (prNumbers , ", " ))
400+ }
401+ }
402+ body += fmt .Sprintf ("- %s%s\n " , sha , prRef )
268403 }
269404 }
270405
406+ // Add standard checklist
407+ body += "\n \n "
408+ body += "- [x] [Required] I have considered whether this PR needs to be cherry-picked to the latest beta branch.\n "
409+ body += "- [x] [Optional] Override Linear Check\n "
410+
271411 cmd := exec .Command ("gh" , "pr" , "create" ,
272412 "--base" , baseBranch ,
273413 "--head" , headBranch ,
0 commit comments