11package cmd
22
33import (
4+ "bufio"
45 "fmt"
56 "os"
67 "os/exec"
@@ -14,39 +15,55 @@ import (
1415// CherryPickOptions holds options for the cherry-pick command
1516type CherryPickOptions struct {
1617 Releases []string
18+ DryRun bool
19+ Yes bool
1720}
1821
1922// NewCherryPickCommand creates a new cherry-pick command
2023func NewCherryPickCommand () * cobra.Command {
2124 opts := & CherryPickOptions {}
2225
2326 cmd := & cobra.Command {
24- Use : "cherry-pick <commit-sha>" ,
25- Short : "Cherry-pick a commit to a release branch" ,
26- Long : `Cherry-pick a commit to a release branch and create a PR.
27+ Use : "cherry-pick <commit-sha> [<commit-sha>...] " ,
28+ Short : "Cherry-pick one or more commits to a release branch" ,
29+ Long : `Cherry-pick one or more commits to a release branch and create a PR.
2730
2831This command will:
29- 1. Find the nearest stable version tag (v*.*.* if --release not specified)
30- 2. Fetch the corresponding release branch (release/vMAJOR.MINOR )
31- 3. Create a hotfix branch with the cherry-picked commit
32+ 1. Find the nearest stable version tag
33+ 2. Fetch the corresponding release branch(es )
34+ 3. Create a hotfix branch with the cherry-picked commit(s)
3235 4. Push and create a PR using the GitHub CLI
3336 5. Switch back to the original branch
3437
35- The --release flag can be specified multiple times to cherry-pick to multiple release branches.` ,
36- Args : cobra .ExactArgs (1 ),
38+ Multiple commits will be cherry-picked in the order specified, similar to git cherry-pick.
39+ The --release flag can be specified multiple times to cherry-pick to multiple release branches.
40+ Example usage:
41+
42+ $ ods cherry-pick foo123 bar456 --release 2.5 --release 2.6` ,
43+ Args : cobra .MinimumNArgs (1 ),
3744 Run : func (cmd * cobra.Command , args []string ) {
3845 runCherryPick (cmd , args , opts )
3946 },
4047 }
4148
4249 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." )
50+ cmd .Flags ().BoolVar (& opts .DryRun , "dry-run" , false , "Perform all local operations but skip pushing to remote and creating PRs" )
51+ cmd .Flags ().BoolVar (& opts .Yes , "yes" , false , "Skip confirmation prompts and automatically proceed" )
4352
4453 return cmd
4554}
4655
4756func runCherryPick (cmd * cobra.Command , args []string , opts * CherryPickOptions ) {
48- commitSHA := args [0 ]
49- log .Debugf ("Cherry-picking commit: %s" , commitSHA )
57+ commitSHAs := args
58+ if len (commitSHAs ) == 1 {
59+ log .Debugf ("Cherry-picking commit: %s" , commitSHAs [0 ])
60+ } else {
61+ log .Debugf ("Cherry-picking %d commits: %s" , len (commitSHAs ), strings .Join (commitSHAs , ", " ))
62+ }
63+
64+ if opts .DryRun {
65+ log .Warning ("=== DRY RUN MODE: No remote operations will be performed ===" )
66+ }
5067
5168 // Save the current branch to switch back later
5269 originalBranch , err := getCurrentBranch ()
@@ -55,10 +72,25 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
5572 }
5673 log .Debugf ("Original branch: %s" , originalBranch )
5774
58- // Get the short SHA for branch naming
59- shortSHA := commitSHA
60- if len (shortSHA ) > 8 {
61- shortSHA = shortSHA [:8 ]
75+ // Get the short SHA(s) for branch naming
76+ var branchSuffix string
77+ if len (commitSHAs ) == 1 {
78+ shortSHA := commitSHAs [0 ]
79+ if len (shortSHA ) > 8 {
80+ shortSHA = shortSHA [:8 ]
81+ }
82+ branchSuffix = shortSHA
83+ } else {
84+ // For multiple commits, use first-last notation
85+ firstSHA := commitSHAs [0 ]
86+ lastSHA := commitSHAs [len (commitSHAs )- 1 ]
87+ if len (firstSHA ) > 8 {
88+ firstSHA = firstSHA [:8 ]
89+ }
90+ if len (lastSHA ) > 8 {
91+ lastSHA = lastSHA [:8 ]
92+ }
93+ branchSuffix = fmt .Sprintf ("%s-%s" , firstSHA , lastSHA )
6294 }
6395
6496 // Determine which releases to target
@@ -68,84 +100,121 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
68100 for _ , rel := range opts .Releases {
69101 releases = append (releases , normalizeVersion (rel ))
70102 }
71- log .Infof ("Using specified release versions: %v" , releases )
103+ log .Debugf ("Using specified release versions: %v" , releases )
72104 } else {
73- // Find the nearest stable tag
74- version , err := findNearestStableTag (commitSHA )
105+ // Find the nearest stable tag using the first commit
106+ version , err := findNearestStableTag (commitSHAs [ 0 ] )
75107 if err != nil {
76108 log .Fatalf ("Failed to find nearest stable tag: %v" , err )
77109 }
110+
111+ // Prompt user for confirmation
112+ if ! opts .Yes {
113+ if ! promptYesNo (fmt .Sprintf ("Auto-detected release version: %s. Continue? (yes/no): " , version )) {
114+ log .Info ("If you want to cherry-pick to a different release, use the --release flag. Exiting..." )
115+ return
116+ }
117+ } else {
118+ log .Infof ("Auto-detected release version: %s" , version )
119+ }
120+
78121 releases = []string {version }
79- log .Infof ("Auto-detected release version: %s" , version )
80122 }
81123
82- // Get commit message for PR title
83- commitMsg , err := getCommitMessage (commitSHA )
84- if err != nil {
85- log .Warnf ("Failed to get commit message, using default title: %v" , err )
86- commitMsg = fmt .Sprintf ("Hotfix: cherry-pick %s" , shortSHA )
124+ // Get commit message(s) for PR title
125+ var prTitle string
126+ if len (commitSHAs ) == 1 {
127+ commitMsg , err := getCommitMessage (commitSHAs [0 ])
128+ if err != nil {
129+ log .Warnf ("Failed to get commit message, using default title: %v" , err )
130+ shortSHA := commitSHAs [0 ]
131+ if len (shortSHA ) > 8 {
132+ shortSHA = shortSHA [:8 ]
133+ }
134+ prTitle = fmt .Sprintf ("chore(hotfix): cherry-pick %s" , shortSHA )
135+ } else {
136+ prTitle = commitMsg
137+ }
138+ } else {
139+ // For multiple commits, use a generic title
140+ prTitle = fmt .Sprintf ("chore(hotfix): cherry-pick %d commits" , len (commitSHAs ))
87141 }
88142
89143 // Process each release
90144 prURLs := []string {}
91145 for _ , release := range releases {
92- log .Infof ("\n --- Processing release %s ---" , release )
93- prURL , err := cherryPickToRelease (commitSHA , shortSHA , release , commitMsg )
146+ log .Infof ("Processing release %s" , release )
147+ prTitleWithRelease := fmt .Sprintf ("%s to release %s" , prTitle , release )
148+ prURL , err := cherryPickToRelease (commitSHAs , branchSuffix , release , prTitleWithRelease , opts .DryRun )
94149 if err != nil {
95150 // Switch back to original branch before exiting on error
96- if checkoutErr := runGitCommand ("checkout " , originalBranch ); checkoutErr != nil {
97- log .Warnf ("Failed to switch back to original branch: %v" , checkoutErr )
151+ if switchErr := runGitCommand ("switch " , "--quiet" , originalBranch ); switchErr != nil {
152+ log .Warnf ("Failed to switch back to original branch: %v" , switchErr )
98153 }
99154 log .Fatalf ("Failed to cherry-pick to release %s: %v" , release , err )
100155 }
101- prURLs = append (prURLs , prURL )
156+ if prURL != "" {
157+ prURLs = append (prURLs , prURL )
158+ }
102159 }
103160
104161 // Switch back to the original branch
105- log .Infof ("\n Switching back to original branch: %s" , originalBranch )
106- if err := runGitCommand ("checkout " , originalBranch ); err != nil {
162+ log .Infof ("Switching back to original branch: %s" , originalBranch )
163+ if err := runGitCommand ("switch" , "--quiet " , originalBranch ); err != nil {
107164 log .Warnf ("Failed to switch back to original branch: %v" , err )
108165 }
109166
110167 // Print all PR URLs
111- log .Info ("\n === Summary ===" )
112168 for i , prURL := range prURLs {
113169 log .Infof ("PR %d: %s" , i + 1 , prURL )
114170 }
115171}
116172
117- // cherryPickToRelease cherry-picks a commit to a specific release branch
118- func cherryPickToRelease (commitSHA , shortSHA , version , commitMsg string ) (string , error ) {
173+ // cherryPickToRelease cherry-picks one or more commits to a specific release branch
174+ func cherryPickToRelease (commitSHAs [] string , branchSuffix , version , prTitle string , dryRun bool ) (string , error ) {
119175 releaseBranch := fmt .Sprintf ("release/%s" , version )
120- hotfixBranch := fmt .Sprintf ("hotfix/%s-%s" , shortSHA , version )
176+ hotfixBranch := fmt .Sprintf ("hotfix/%s-%s" , branchSuffix , version )
121177
122178 // Fetch the release branch
123179 log .Infof ("Fetching release branch: %s" , releaseBranch )
124- if err := runGitCommand ("fetch" , "origin" , releaseBranch ); err != nil {
180+ if err := runGitCommand ("fetch" , "--prune" , "--quiet" , " origin" , releaseBranch ); err != nil {
125181 return "" , fmt .Errorf ("failed to fetch release branch %s: %w" , releaseBranch , err )
126182 }
127183
128184 // Create the hotfix branch from the release branch
129185 log .Infof ("Creating hotfix branch: %s" , hotfixBranch )
130- if err := runGitCommand ("checkout" , "-b" , hotfixBranch , fmt .Sprintf ("origin/%s" , releaseBranch )); err != nil {
186+ if err := runGitCommand ("checkout" , "--quiet" , "- b" , hotfixBranch , fmt .Sprintf ("origin/%s" , releaseBranch )); err != nil {
131187 return "" , fmt .Errorf ("failed to create hotfix branch: %w" , err )
132188 }
133189
134- // Cherry-pick the commit
135- log .Infof ("Cherry-picking commit: %s" , commitSHA )
136- if err := runGitCommand ("cherry-pick" , commitSHA ); err != nil {
137- return "" , fmt .Errorf ("failed to cherry-pick commit: %w" , err )
190+ // Cherry-pick the commits
191+ if len (commitSHAs ) == 1 {
192+ log .Infof ("Cherry-picking commit: %s" , commitSHAs [0 ])
193+ } else {
194+ log .Infof ("Cherry-picking %d commits: %s" , len (commitSHAs ), strings .Join (commitSHAs , " " ))
195+ }
196+
197+ // Build git cherry-pick command with all commits
198+ cherryPickArgs := append ([]string {"cherry-pick" }, commitSHAs ... )
199+ if err := runGitCommand (cherryPickArgs ... ); err != nil {
200+ return "" , fmt .Errorf ("failed to cherry-pick commits: %w" , err )
201+ }
202+
203+ if dryRun {
204+ log .Warnf ("[DRY RUN] Would push hotfix branch: %s" , hotfixBranch )
205+ log .Warnf ("[DRY RUN] Would create PR from %s to %s" , hotfixBranch , releaseBranch )
206+ return "" , nil
138207 }
139208
140209 // Push the hotfix branch
141210 log .Infof ("Pushing hotfix branch: %s" , hotfixBranch )
142- if err := runGitCommand ("push" , "-u" , "origin" , hotfixBranch ); err != nil {
211+ if err := runGitCommand ("push" , "--quiet" , "- u" , "origin" , hotfixBranch ); err != nil {
143212 return "" , fmt .Errorf ("failed to push hotfix branch: %w" , err )
144213 }
145214
146215 // Create PR using GitHub CLI
147216 log .Info ("Creating PR..." )
148- prURL , err := createPR (hotfixBranch , releaseBranch , commitMsg , commitSHA )
217+ prURL , err := createPR (hotfixBranch , releaseBranch , prTitle , commitSHAs )
149218 if err != nil {
150219 return "" , fmt .Errorf ("failed to create PR: %w" , err )
151220 }
@@ -154,12 +223,32 @@ func cherryPickToRelease(commitSHA, shortSHA, version, commitMsg string) (string
154223 return prURL , nil
155224}
156225
226+ // promptYesNo prompts the user with a yes/no question and returns true for yes, false for no
227+ func promptYesNo (prompt string ) bool {
228+ reader := bufio .NewReader (os .Stdin )
229+ for {
230+ fmt .Print (prompt )
231+ response , err := reader .ReadString ('\n' )
232+ if err != nil {
233+ log .Fatalf ("Failed to read input: %v" , err )
234+ }
235+ response = strings .TrimSpace (strings .ToLower (response ))
236+ if response == "yes" || response == "y" || response == "" {
237+ return true
238+ }
239+ if response == "no" || response == "n" {
240+ return false
241+ }
242+ fmt .Println ("Please enter 'yes' or 'no'" )
243+ }
244+ }
245+
157246// getCurrentBranch returns the name of the current git branch
158247func getCurrentBranch () (string , error ) {
159- cmd := exec .Command ("git" , "rev-parse " , "--abbrev-ref" , "HEAD " )
248+ cmd := exec .Command ("git" , "branch " , "--show-current " )
160249 output , err := cmd .Output ()
161250 if err != nil {
162- return "" , fmt .Errorf ("git rev-parse failed: %w" , err )
251+ return "" , fmt .Errorf ("git branch failed: %w" , err )
163252 }
164253 return strings .TrimSpace (string (output )), nil
165254}
@@ -198,7 +287,9 @@ func findNearestStableTag(commitSHA string) (string, error) {
198287func runGitCommand (args ... string ) error {
199288 log .Debugf ("Running: git %s" , strings .Join (args , " " ))
200289 cmd := exec .Command ("git" , args ... )
201- cmd .Stdout = os .Stdout
290+ if log .IsLevelEnabled (log .DebugLevel ) {
291+ cmd .Stdout = os .Stdout
292+ }
202293 cmd .Stderr = os .Stderr
203294 return cmd .Run ()
204295}
@@ -214,8 +305,16 @@ func getCommitMessage(commitSHA string) (string, error) {
214305}
215306
216307// createPR creates a pull request using the GitHub CLI
217- func createPR (headBranch , baseBranch , title , commitSHA string ) (string , error ) {
218- body := fmt .Sprintf ("Cherry-pick of commit %s to %s branch." , commitSHA , baseBranch )
308+ func createPR (headBranch , baseBranch , title string , commitSHAs []string ) (string , error ) {
309+ var body string
310+ if len (commitSHAs ) == 1 {
311+ body = fmt .Sprintf ("Cherry-pick of commit %s to %s branch." , commitSHAs [0 ], baseBranch )
312+ } else {
313+ body = fmt .Sprintf ("Cherry-pick of %d commits to %s branch:\n \n " , len (commitSHAs ), baseBranch )
314+ for _ , sha := range commitSHAs {
315+ body += fmt .Sprintf ("- %s\n " , sha )
316+ }
317+ }
219318
220319 cmd := exec .Command ("gh" , "pr" , "create" ,
221320 "--base" , baseBranch ,
0 commit comments