Skip to content

Commit decca26

Browse files
authored
chore(devtools): ods cherry-pick QOL (#7708)
1 parent 1c49073 commit decca26

File tree

3 files changed

+289
-44
lines changed

3 files changed

+289
-44
lines changed

tools/ods/cmd/cherry-pick.go

Lines changed: 170 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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
230337
func 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
238352
func 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\nOriginal 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,

tools/ods/cmd/run-ci.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,25 +133,15 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
133133
// Create or update the CI branch from FETCH_HEAD
134134
if originalBranch == ciBranch {
135135
// Already on the CI branch - stash any uncommitted changes before resetting
136-
stashed := false
137-
if git.HasUncommittedChanges() {
138-
log.Info("Stashing uncommitted changes...")
139-
if err := git.RunCommand("stash", "--include-untracked"); err != nil {
140-
log.Fatalf("Failed to stash changes: %v", err)
141-
}
142-
stashed = true
136+
stashResult, err := git.StashChanges()
137+
if err != nil {
138+
log.Fatalf("Failed to stash changes: %v", err)
143139
}
144140
log.Infof("Already on %s, resetting to fork's HEAD", ciBranch)
145141
if err := git.RunCommand("reset", "--hard", "FETCH_HEAD"); err != nil {
146142
log.Fatalf("Failed to reset branch to fork's HEAD: %v", err)
147143
}
148-
if stashed {
149-
log.Info("Restoring stashed changes...")
150-
if err := git.RunCommand("stash", "pop"); err != nil {
151-
log.Warnf("Failed to restore stashed changes (may have conflicts): %v", err)
152-
log.Info("Your changes are still in the stash. Run 'git stash pop' to restore them manually.")
153-
}
154-
}
144+
git.RestoreStash(stashResult)
155145
} else {
156146
// Delete branch if it already exists locally (to ensure we're in sync with fork)
157147
if git.BranchExists(ciBranch) {

0 commit comments

Comments
 (0)