git-maintain 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,944 @@
1
+ # Main module for git-maintain repository maintenance tool.
2
+ module GitMaintain
3
+
4
+ # Class representing a single stable branch and actions that can be performed on it.
5
+ class Branch < Common
6
+ # List of all available maintenance actions.
7
+ ACTION_LIST = [
8
+ :cp, :steal, :list,
9
+ :merge, :pull, :push, :monitor,
10
+ :release, :reset, :create, :delete
11
+ ]
12
+ # Actions that do not require updating the remote repository first.
13
+ NO_FETCH_ACTIONS = [
14
+ :cp, :merge, :monitor, :release, :delete
15
+ ]
16
+ # Actions that do not require checking out the local branch before running.
17
+ NO_CHECKOUT_ACTIONS = [
18
+ :create, :delete, :list, :push, :monitor
19
+ ]
20
+ # Actions that run on all branches, regardless of target versions.
21
+ ALL_BRANCHES_ACTIONS = [
22
+ :create
23
+ ]
24
+ # Description map of actions for CLI help output.
25
+ ACTION_HELP = {
26
+ :cp => "Backport commits and eventually push them to github",
27
+ :create => "Create missing local branches from all the stable branches",
28
+ :delete => "Delete all local branches using the suffix",
29
+ :steal => "Steal commit from upstream that fixes commit in the branch or were tagged as stable",
30
+ :list => "List commit present in the branch but not in the stable branch",
31
+ :merge => "Merge branch with suffix specified in -m <suff> into the main branch",
32
+ :push => "Push branches to github for validation",
33
+ :pull => "Rebase branches on top of the upstream one",
34
+ :monitor => "Check the CI state of all branches",
35
+ :release => "Create new release on all concerned branches",
36
+ :reset => "Reset branch against upstream",
37
+ }
38
+ # Configure action-specific command line options.
39
+ #
40
+ # @param action [Symbol] Selected action name
41
+ # @param optsParser [OptionParser] The OptionParser instance to configure
42
+ # @param opts [Hash] The options hash to populate
43
+ def self.set_opts(action, optsParser, opts)
44
+ opts[:base_ver] = 0
45
+ opts[:version] = []
46
+ opts[:commits] = []
47
+ opts[:breaker] = nil
48
+ opts[:do_merge] = false
49
+ opts[:push_force] = false
50
+ opts[:no_ci] = false
51
+ opts[:steal_base] = nil
52
+ opts[:check_only] = false
53
+ opts[:fetch] = nil
54
+ opts[:watch] = false
55
+ opts[:delete_remote] = false
56
+ opts[:no_edit] = false
57
+ opts[:stable] = false
58
+
59
+ optsParser.on("-v", "--base-version [MIN_VER]", Integer, "Older release to consider.") {
60
+ |val| opts[:base_ver] = val}
61
+ optsParser.on("-V", "--version [regexp]", Regexp, "Regexp to filter versions.") {
62
+ |val| opts[:version] << val}
63
+
64
+ if ALL_BRANCHES_ACTIONS.index(action) == nil &&
65
+ action != :merge &&
66
+ action != :delete then
67
+ optsParser.on("-B", "--manual-branch <branch name>", "Work on a specific (non-stable) branch.") {
68
+ |val| opts[:manual_branch] = val}
69
+ end
70
+
71
+ if NO_FETCH_ACTIONS.index(action) == nil
72
+ optsParser.on("--[no-]fetch", "Enable/Disable fetch of stable repo.") {
73
+ |val| opts[:fetch] = val}
74
+ end
75
+
76
+ case action
77
+ when :cp
78
+ optsParser.banner += "-c <sha1> [-c <sha1> ...]"
79
+ optsParser.on("-c", "--sha1 [SHA1]", String, "Commit to cherry-pick. Can be used multiple time.") {
80
+ |val| opts[:commits] << val}
81
+ optsParser.on("--breaker SHA", String, "Only apply patch if the breaker (or a cherry-pick of it) is contained within the branch.") {
82
+ |val| opts[:breaker] = val}
83
+ when :delete
84
+ optsParser.on("--remote", "Delete the remote staging branch instead of the local ones.") {
85
+ |val| opts[:delete_remote] = true}
86
+ when :list
87
+ optsParser.on("--stable", "List unreleased commits in the upstream stable branch.") {
88
+ opts[:stable] = true }
89
+ when :merge
90
+ optsParser.banner += "-m <suffix>"
91
+ optsParser.on("-m", "--merge [SUFFIX]", "Merge branch with suffix.") {
92
+ |val| opts[:do_merge] = val}
93
+ when :monitor
94
+ optsParser.on("-w", "--watch <PERIOD>", Integer,
95
+ "Watch and refresh CI status every <PERIOD>.") {
96
+ |val| opts[:watch] = val}
97
+ optsParser.on("--stable", "Check CI status on stable repo.") {
98
+ opts[:stable] = true }
99
+ when :pull
100
+ optsParser.on("--stable", "List unreleased commits in the upstream stable branch.") {
101
+ opts[:stable] = true }
102
+ when :push
103
+ optsParser.banner += "[-f]"
104
+ optsParser.on("-f", "--force", "Add --force to git push (for 'push' action).") {
105
+ opts[:push_force] = true}
106
+ optsParser.on("--stable", "Push to stable repo.") {
107
+ opts[:stable] = true }
108
+ optsParser.banner += "[-T]"
109
+ optsParser.on("-T", "--no-ci", "Ignore CI build status and push anyway.") {
110
+ opts[:no_ci] = true}
111
+ optsParser.on("-c", "--check", "Check if there is something to be pushed.") {
112
+ opts[:check_only] = true}
113
+ when :release
114
+ optsParser.on("--no-edit", "Do not edit release commit nor tag.") {
115
+ opts[:no_edit] = true }
116
+ when :steal
117
+ optsParser.banner += "[-a][-b <HEAD>]"
118
+ optsParser.on("-a", "--all", "Check all commits from master. "+
119
+ "By default only new commits (since last successful run) are considered.") {
120
+ |val| opts[:steal_base] = :all}
121
+ optsParser.on("-b", "--base <HEAD>", "Check all commits from this commit. "+
122
+ "By default only new commits (since last successful run) are considered.") {
123
+ |val| opts[:steal_base] = val}
124
+ end
125
+ end
126
+
127
+ # Sanity check and normalize the parsed options for the given action.
128
+ #
129
+ # @param opts [Hash] Options hash to validate and configure
130
+ # @raise [InvalidArgumentError] If options are invalid or conflicting
131
+ def self.check_opts(opts)
132
+ if opts[:action] == :release then
133
+ if opts[:br_suff] != "master" then
134
+ raise InvalidArgumentError.new("Action #{opts[:action]} can only be done on 'master' suffixed branches")
135
+ end
136
+ end
137
+ if opts[:action] == :delete && opts[:delete_remote] != true then
138
+ if opts[:br_suff] == "master" then
139
+ raise InvalidArgumentError.new("Action #{opts[:action]} can NOT be done on 'master' suffixed branches")
140
+ end
141
+ end
142
+ if opts[:action] == :push
143
+ if opts[:stable] == true && opts[:push_force] == true then
144
+ raise InvalidArgumentError.new("Action push can NOT be use both --stable and --force")
145
+ end
146
+ end
147
+ opts[:version] = [ /.*/ ] if opts[:version].length == 0
148
+ end
149
+
150
+ # Factory method to load an instance of the Branch class or its repository-specific subclass.
151
+ #
152
+ # @param repo [Repo] The Repo instance
153
+ # @param version [String] The branch version (e.g., '1.0')
154
+ # @param ci [CI] The CI instance
155
+ # @param branch_suff [String] Suffix of the branch (e.g., 'master')
156
+ # @return [Branch] The loaded Branch instance (or subclass)
157
+ # @raise [GitMaintainError] If class loading fails
158
+ def self.load(repo, version, ci, branch_suff)
159
+ repo_name = File.basename(repo.path)
160
+ return GitMaintain::loadClass(Branch, repo_name, repo, version, ci, branch_suff)
161
+ end
162
+
163
+ protected
164
+ # Print the diff between two branches and prompt for confirmation.
165
+ #
166
+ # @param opts [Hash] Options hash
167
+ # @param br1 [String] First branch/ref
168
+ # @param br2 [String] Second branch/ref
169
+ # @param action_msg [String] Action message to prompt (e.g. 'submit')
170
+ # @return [String] User prompt response ('y' or 'n')
171
+ # @raise [RunError] If running git log fails
172
+ def checkLog(opts, br1, br2, action_msg)
173
+ puts "Diff between #{br1} and #{br2}"
174
+ puts `git log --format=oneline #{br1} ^#{br2}`
175
+ return "n" if action_msg.to_s() == ""
176
+ rep = confirm(opts, "#{action_msg} this branch")
177
+ return rep
178
+ end
179
+
180
+ # Print the diff/log between two branches.
181
+ #
182
+ # @param opts [Hash] Options hash
183
+ # @param br1 [String] First branch/ref
184
+ # @param br2 [String] Second branch/ref
185
+ # @raise [RunError] If running git log fails
186
+ def showLog(opts, br1, br2)
187
+ log(:INFO, "Diff between #{br1} and #{br2}")
188
+ puts `git log --format=oneline #{br1} ^#{br2}`
189
+ end
190
+
191
+ public
192
+ # Initialize a new Branch instance, resolving local/remote branches, head references, and stable base.
193
+ #
194
+ # @param repo [Repo] The Repo instance
195
+ # @param version [String] Suffix version (e.g. '1.0') or branch name
196
+ # @param ci [CI] The CI instance
197
+ # @param branch_suff [String] Branch suffix (e.g. 'master')
198
+ # @raise [NoRefError] If resolving git references fails
199
+ def initialize(repo, version, ci, branch_suff)
200
+ GitMaintain::checkDirectConstructor(self.class)
201
+
202
+ @path = repo.path
203
+ @repo = repo
204
+ @ci = ci
205
+ @version = version
206
+ @branch_suff = branch_suff
207
+
208
+ if version =~ /^[0-9]+$/
209
+ @local_branch = @repo.versionToLocalBranch(@version, @branch_suff)
210
+ @remote_branch = @repo.versionToStableBranch(@version)
211
+ @branch_type = :std
212
+ @verbose_name = "v"+version
213
+ else
214
+ @remote_branch = @local_branch = version
215
+ @branch_type = :user_specified
216
+ @verbose_name = version
217
+ end
218
+
219
+ @head = @repo.ref_exist?(@local_branch)
220
+ @valid_ref = "#{@repo.valid_repo}/#{@local_branch}"
221
+ @remote_ref = "#{@repo.stable_repo}/#{@remote_branch}"
222
+ @stable_head =
223
+ begin
224
+ @repo.ref_exist?(@remote_ref)
225
+ rescue
226
+ nil
227
+ end
228
+ case @branch_type
229
+ when :std
230
+ @stable_base = @repo.findStableBase(@local_branch)
231
+ when :user_specified
232
+ @stable_base = @remote_ref
233
+ end
234
+ end
235
+ attr_reader :version, :local_branch, :head, :remote_branch, :valid_ref, :remote_ref, :stable_head,
236
+ :verbose_name, :exists, :stable_base
237
+
238
+
239
+ # Check if the branch matches specified target version options.
240
+ #
241
+ # @param opts [Hash] Options hash with version/base filters
242
+ # @return [Boolean, Symbol] `true` if targeted, `:too_old` or `:no_match` otherwise
243
+ def is_targetted?(opts)
244
+ return true if @branch_type == :user_specified
245
+ if @version.to_i < opts[:base_ver] then
246
+ return :too_old
247
+ end
248
+ opts[:version].each() {|regexp|
249
+ return true if @version =~ regexp
250
+ }
251
+ return :no_match
252
+ end
253
+
254
+ # Checkout the git repository to this branch's local branch.
255
+ #
256
+ # @raise [RunError] If git checkout execution fails
257
+ def checkout()
258
+ runGitInteractive("checkout -q #{@local_branch}")
259
+ end
260
+
261
+ # Backport/cherry-pick the given array of commits into this branch.
262
+ #
263
+ # @param opts [Hash] Options hash containing `:commits` to pick
264
+ # @raise [CPAbort] If cherry-pick is aborted by the user
265
+ def cp(opts)
266
+ if opts[:breaker] != nil then
267
+ if !is_in_tree?(opts[:breaker]) then
268
+ log(:INFO, "Skipping #{verbose_name}: breaker #{opts[:breaker]} not in tree")
269
+ return
270
+ end
271
+ end
272
+ opts[:commits].each(){|commit|
273
+ if is_in_tree?(commit) then
274
+ log(:INFO, "Commit #{commit} is already in tree, skipping")
275
+ next
276
+ end
277
+ prev_head=runGit("rev-parse HEAD")
278
+ log(:INFO, "Applying #{@repo.getCommitHeadline(commit)}")
279
+ begin
280
+ runGitInteractive("cherry-pick #{commit}")
281
+ rescue RunError
282
+ begin
283
+ cp_fix(opts, commit)
284
+ rescue CPSkip => e
285
+ log(:INFO, e.message)
286
+ rescue CPAbort => e
287
+ log(:INFO, "Cherry-pick aborted by user.")
288
+ raise e
289
+ end
290
+ end
291
+ new_head=runGit("rev-parse HEAD")
292
+ # Do not make commit pretty if it was not applied
293
+ if new_head != prev_head
294
+ make_pretty(commit)
295
+ end
296
+ }
297
+ end
298
+
299
+ # Steal upstream commits that are not present in this branch.
300
+ # Uses `steal_all` and marks the last successful run with a git tag if successful.
301
+ #
302
+ # @param opts [Hash] Options hash
303
+ # @raise [RunError] If git tags/execution fails
304
+ def steal(opts)
305
+ base_ref=@stable_base
306
+
307
+ # If we are not force checking everything,
308
+ # try to start from the last tag we steal upto
309
+ case opts[:steal_base]
310
+ when nil
311
+ begin
312
+ sha = runGit("rev-parse 'git-maintain/steal/last/#{@stable_base}' 2>&1")
313
+ base_ref=sha
314
+ log(:VERBOSE, "Starting from last successfull run:")
315
+ log(:VERBOSE, @repo.getCommitHeadline(base_ref))
316
+ rescue RunError
317
+ # No matching tag found. Not an issue
318
+ end
319
+ when :all
320
+ base_ref=@stable_base
321
+ else
322
+ begin
323
+ sha = runGit("rev-parse #{opts[:steal_base]} 2>&1")
324
+ base_ref=sha
325
+ log(:VERBOSE, "Starting from base:")
326
+ log(:VERBOSE, @repo.getCommitHeadline(base_ref))
327
+ rescue RunError
328
+ crit("Could not find specified base '#{opts[:steal_base]}'")
329
+ end
330
+ end
331
+
332
+ master_sha=runGit("rev-parse origin/master")
333
+
334
+ begin
335
+ steal_all(opts, "#{base_ref}..#{master_sha}", true)
336
+
337
+ # We picked all the commits (or nothing happened)
338
+ # Mark the current master as the last checked point so we
339
+ # can just steal from this point on the next run
340
+ runGit("tag -f 'git-maintain/steal/last/#{@stable_base}' origin/master")
341
+ log(:VERBOSE, "Marking new last successfull run at:")
342
+ log(:VERBOSE, @repo.getCommitHeadline(master_sha))
343
+ rescue CPSkip
344
+ # Ignore the error
345
+ end
346
+ end
347
+
348
+ # List all unreleased stable commits or commits in this branch but not in stable.
349
+ #
350
+ # @param opts [Hash] Options hash
351
+ # @raise [RunError] If running git commands fails
352
+ def list(opts)
353
+ log(:INFO, "Working on #{@verbose_name}")
354
+ if opts[:stable] == true then
355
+ # List commits in the stable_branch that are no in the latest release
356
+ showLog(opts, @remote_ref, runGit("describe --abbrev=0 #{@local_branch}"))
357
+ else
358
+ # List commits in the branch that are no in the stable branch
359
+ showLog(opts, @local_branch, @remote_ref)
360
+ end
361
+ end
362
+
363
+ # Merge the specified merge branch into this branch.
364
+ #
365
+ # @param opts [Hash] Options hash containing `:do_merge` branch name
366
+ # @raise [RunError] If running git merge or system commands fails
367
+ def merge(opts)
368
+ merge_branch = @repo.versionToLocalBranch(@version, opts[:do_merge])
369
+
370
+ # Make sure branch exists
371
+ begin
372
+ hash_to_merge = @repo.ref_exist?(merge_branch)
373
+ rescue NoRefError
374
+ log(:INFO, "Branch #{merge_branch} does not exists. Skipping...")
375
+ return
376
+ end
377
+
378
+ # See if there is anything worth merging
379
+ merge_base_hash = runGit("merge-base #{merge_branch} #{@local_branch}")
380
+ if merge_base_hash == hash_to_merge then
381
+ log(:INFO, "Branch #{merge_branch} has no commit that needs to be merged")
382
+ return
383
+ end
384
+
385
+ rep = checkLog(opts, merge_branch, @local_branch, "merge")
386
+ if rep == "y" then
387
+ begin
388
+ runGitInteractive("merge #{merge_branch}")
389
+ rescue RunError
390
+ log(:WARNING, "Merge failure. Starting bash for manual fixes. Exit shell to continue")
391
+ runBash("PS1_WARNING='MERGING'")
392
+ end
393
+ else
394
+ log(:INFO, "Skipping merge")
395
+ return
396
+ end
397
+ end
398
+
399
+ # Pull/rebase the current branch against the upstream stable or validation reference.
400
+ #
401
+ # @param opts [Hash] Options hash
402
+ # @raise [NoRefError] If checking remote ref existence fails with unexpected ref errors
403
+ # @raise [RunError] If rebasing fails
404
+ def pull(opts)
405
+ remoteRef = opts[:stable] == true ? @remote_ref : @valid_ref
406
+
407
+ # Make sure branch exists
408
+ begin
409
+ @repo.ref_exist?(remoteRef)
410
+ rescue NoRefError
411
+ log(:INFO, "Branch #{remoteRef} does not exists. Skipping...")
412
+ return
413
+ end
414
+ runGitInteractive("rebase #{remoteRef}")
415
+
416
+ end
417
+ # Push the branch to the validation or stable repository.
418
+ # Saves the push specs to `opts[:push_branches]` accumulator array.
419
+ #
420
+ # @param opts [Hash] Options hash containing configuration and the accumulator array
421
+ # @raise [GitMaintainError] If CI checks fail or other git/CI errors occur
422
+ def push(opts)
423
+ remoteRef = opts[:stable] == true ? @remote_ref : @valid_ref
424
+
425
+ # Check both where we want to push and the final remote_ref
426
+ # We may have destroyed the validation branch but if we already merged
427
+ # in the final repo, no need to worry about it.
428
+ if same_sha?(@local_branch, remoteRef) ||
429
+ same_sha?(@local_branch, @remote_ref) then
430
+ log(:INFO, "Nothing to push on #{@local_branch}")
431
+ return
432
+ end
433
+
434
+ # For stable branches, we need to check for CI
435
+ if opts[:stable] == true &&
436
+ (opts[:no_ci] != true && @NO_CI != true) &&
437
+ @ci.checkValidState(self, @head) != true then
438
+ log(:WARNING, "Build is not passed on CI. Skipping push to stable")
439
+ return
440
+ end
441
+
442
+ if opts[:check_only] == true then
443
+ checkLog(opts, @local_branch, @remote_ref, "")
444
+ return
445
+ end
446
+
447
+ # For validation/CI push, let's go and push already
448
+ if opts[:stable] != true
449
+ opts[:push_branches] ||= []
450
+ opts[:push_branches] << "#{@local_branch}:#{@local_branch}"
451
+ return
452
+ end
453
+
454
+ # For stable, we need to confirm with the user that he really wants to push
455
+ rep = checkLog(opts, @local_branch, @remote_ref, "submit")
456
+ if rep == "y" then
457
+ opts[:push_branches] ||= []
458
+ opts[:push_branches] << "#{@local_branch}:#{@remote_branch}"
459
+ else
460
+ log(:INFO, "Skipping push to stable")
461
+ return
462
+ end
463
+ end
464
+
465
+ # Run the epilogue for the push action, executing the accumulated pushes.
466
+ #
467
+ # @param opts [Hash] Options hash containing configuration and accumulated branch specs in `opts[:push_branches]`
468
+ # @param branches [Array] Ignored branch list from map send (we use accumulator in `opts[:push_branches]`)
469
+ # @raise [RunError] If git push execution fails
470
+ def push_epilogue(opts, branches)
471
+ push_list = opts[:push_branches] || []
472
+ return if push_list.length == 0
473
+
474
+ repo = (opts[:stable] == true) ? opts[:repo].stable_repo : opts[:repo].valid_repo
475
+ opts[:repo].runGit("push #{opts[:push_force] == true ? "-f" : ""} "+
476
+ "#{repo} #{push_list.join(" ")}")
477
+ end
478
+
479
+ # Monitor the build status on CI for the branch.
480
+ # Displays the current status (e.g. success, started, errored) and handles prompt to show logs.
481
+ #
482
+ # @param opts [Hash] Options hash
483
+ # @raise [GitMaintainError] If querying CI state or fetching logs fails
484
+ def monitor(opts)
485
+ ts = st = head = nil
486
+ suff=""
487
+ if opts[:stable] == true then
488
+ st = @ci.getStableState(self, @stable_head)
489
+ ts = @ci.getStableTS(self, @stable_head) if st == "started"
490
+ else
491
+ st = @ci.getValidState(self, @head)
492
+ ts = @ci.getValidTS(self, @head) if st == "started"
493
+ end
494
+
495
+ case st
496
+ when "started"
497
+ suff= " at #{ts}"
498
+ end
499
+ log(:INFO, "Status for v#{@version}: " + st + suff)
500
+ if @ci.isErrored(self, st) && opts[:watch] == false
501
+ rep = "y"
502
+ suff=""
503
+ while rep == "y"
504
+ rep = confirm(opts, "see the build log#{suff}")
505
+ if rep == "y" then
506
+ log = @ci.getValidLog(self, @head)
507
+ tmp = `mktemp`.chomp()
508
+ tmpfile = File.open(tmp, "w+")
509
+ tmpfile.puts(log)
510
+ tmpfile.close()
511
+ system("less -r #{tmp}")
512
+ `rm -f #{tmp}`
513
+ end
514
+ suff=" again"
515
+ end
516
+ end
517
+ end
518
+
519
+ # Reset the branch to the upstream stable reference (warning: hard reset).
520
+ #
521
+ # @param opts [Hash] Options hash
522
+ # @raise [RunError] If running git commands fails
523
+ def reset(opts)
524
+ if same_sha?(@local_branch, @remote_ref) then
525
+ log(:INFO, "Nothing to reset")
526
+ return
527
+ end
528
+
529
+ rep = checkLog(opts, @local_branch, @remote_ref, "reset")
530
+ if rep == "y" then
531
+ runGit("reset --hard #{@remote_ref}")
532
+ else
533
+ log(:INFO, "Skipping reset")
534
+ return
535
+ end
536
+ end
537
+
538
+ # Create a release on the current branch (dummy/unsupported method for the base Branch class).
539
+ #
540
+ # @param opts [Hash] Options hash
541
+ def release(opts)
542
+ log(:ERROR,"#No release command available for this repo")
543
+ end
544
+
545
+ # Create the missing local branch tracking the upstream remote stable reference.
546
+ #
547
+ # @param opts [Hash] Options hash
548
+ # @raise [RunError] If git branch creation fails
549
+ def create(opts)
550
+ return if @head != ""
551
+ log(:INFO, "Creating missing #{@local_branch} from #{@remote_ref}")
552
+ runGit("branch #{@local_branch} #{@remote_ref}")
553
+ end
554
+
555
+ # Delete the branch (locally or remotely).
556
+ # Saves the deletion specs to `opts[:delete_branches]` accumulator array.
557
+ #
558
+ # @param opts [Hash] Options hash containing configuration and the accumulator array
559
+ # @raise [NoRefError] If checking remote ref existence fails with unexpected ref errors
560
+ def delete(opts)
561
+ if opts[:delete_remote] == true then
562
+ begin
563
+ @repo.ref_exist?("#{@repo.valid_repo}/#{@local_branch}")
564
+ rescue NoRefError
565
+ log(:DEBUG, "Skipping non existing remote branch #{@local_branch}.")
566
+ return
567
+ end
568
+ msg = "delete remote branch #{@repo.valid_repo}/#{@local_branch}"
569
+ else
570
+ msg = "delete branch #{@local_branch}"
571
+ end
572
+ rep = confirm(opts, msg)
573
+ if rep == "y" then
574
+ opts[:delete_branches] ||= []
575
+ opts[:delete_branches] << @local_branch
576
+ else
577
+ log(:INFO, "Skipping deletion")
578
+ return
579
+ end
580
+ end
581
+
582
+ # Run the epilogue for the delete action, executing the accumulated deletions.
583
+ #
584
+ # @param opts [Hash] Options hash containing configuration and accumulated branch specs in `opts[:delete_branches]`
585
+ # @param branches [Array] Ignored branch list from map send (we use accumulator in `opts[:delete_branches]`)
586
+ # @raise [RunError] If git branch deletion fails
587
+ def delete_epilogue(opts, branches)
588
+ delete_list = opts[:delete_branches] || []
589
+ return if delete_list.length == 0
590
+ puts "Deleting #{opts[:delete_remote] == true ? "remote" : "local"} branches: #{delete_list.join(" ")}"
591
+ rep = confirm(opts, "continue", true)
592
+ if rep != "y" then
593
+ log(:INFO, "Cancelling")
594
+ return
595
+ end
596
+ if opts[:delete_remote] == true then
597
+ opts[:repo].runGit("push #{opts[:repo].valid_repo} #{delete_list.map(){|x| ":" + x}.join(" ")}")
598
+ else
599
+ opts[:repo].runGit("branch -D #{delete_list.join(" ")}")
600
+ end
601
+ end
602
+
603
+ private
604
+ # Add the given commit to the git notes blacklist for the current branch.
605
+ #
606
+ # @param commit [String] The commit SHA to blacklist
607
+ # @raise [RunError] If running git notes fails
608
+ def add_blacklist(commit)
609
+ runGit("notes append -m \"#{@local_branch}\" #{commit}")
610
+ end
611
+
612
+ def is_blacklisted?(commit)
613
+ begin
614
+ runGit("notes show #{commit} 2> /dev/null").split("\n").each(){|br|
615
+ return true if br == @local_branch
616
+ }
617
+ rescue
618
+ end
619
+ return false
620
+ end
621
+
622
+ # Rewrite the commit message of the HEAD commit to reference the upstream commit.
623
+ # Adds an "[ Upstream commit <SHA> ]" label.
624
+ #
625
+ # @param orig_commit [String] Original upstream commit SHA or reference
626
+ # @param commit [String] Override commit SHA to display in the label
627
+ # @raise [RunError] If running git commands fails
628
+ def make_pretty(orig_commit, commit="")
629
+ orig_sha=runGit("rev-parse #{orig_commit}")
630
+ msg_commit = (commit.to_s() == "") ? orig_sha : commit
631
+
632
+ msg_path=`mktemp`.chomp()
633
+ msg_file = File.open(msg_path, "w+")
634
+ msg_file.puts runGit("log -1 --format=\"%s%n%n[ Upstream commit #{msg_commit} ]%n%n%b\" #{orig_commit}")
635
+ msg_file.close()
636
+ runGit("commit -s --amend -F #{msg_path}")
637
+ `rm -f #{msg_path}`
638
+ end
639
+
640
+ def is_in_tree?(commit, src_commit=commit)
641
+ fullhash=nil
642
+ begin
643
+ fullhash=@repo.ref_exist?(commit)
644
+ rescue NoRefError
645
+ # This might happen if someone pointed to a commit that doesn't exist in our
646
+ # tree.
647
+ log(:WARNING, "Commit #{src_commit} points to a SHA #{commit} not in tree")
648
+ return false
649
+ end
650
+
651
+ # Hope for the best, same commit is/isn't in the current branch
652
+ if runGit("merge-base #{fullhash} HEAD") == fullhash then
653
+ return true
654
+ end
655
+
656
+ # Grab the subject, since commit sha1 is different between branches we
657
+ # have to look it up based on subject.
658
+ subj=@repo.getCommitSubj(commit)
659
+
660
+ # Try and find if there's a commit with given subject the hard way
661
+ runGit("log --pretty=\"%H\" -F --grep \"#{subj.gsub("\"", '\\"')}\" "+
662
+ "#{@stable_base}..HEAD").split("\n").each(){|cmt|
663
+ cursubj=runGit("log -1 --format=\"%s\" #{cmt}")
664
+ if cursubj == subj then
665
+ return true
666
+ end
667
+ }
668
+ return false
669
+ end
670
+
671
+ def is_relevant?(commit)
672
+ # Let's grab the commit that this commit fixes (if exists (based on the "Fixes:" tag)).
673
+ fixescmt=runGit("log -1 #{commit} | grep -i \"fixes:\" | head -n 1 | "+
674
+ "sed -e 's/^[ \\t]*//' | cut -f 2 -d ':' | "+
675
+ "sed -e 's/^[ \\t]*//' -e 's/\\([0-9a-f]\\+\\)(/\\1 (/' | cut -f 1 -d ' '")
676
+
677
+ # If this commit fixes anything, but the broken commit isn't in our branch we don't
678
+ # need this commit either.
679
+ if fixescmt != "" then
680
+ if is_in_tree?(fixescmt, commit) then
681
+ return true
682
+ else
683
+ return false
684
+ end
685
+ end
686
+
687
+ if runGit("show #{commit} | grep -i 'stable@' | wc -l") == "0" then
688
+ return false
689
+ end
690
+
691
+ # Let's see if there's a version tag in this commit
692
+ full=runGit("show #{commit} | grep -i 'stable@'").gsub(/.* #?/, "")
693
+
694
+ # Sanity check our extraction
695
+ if full =~ /stable/ then
696
+ return false
697
+ end
698
+
699
+ full = runGit("rev-parse #{full}^{commit}")
700
+
701
+ # Make sure our branch contains this version
702
+ if runGit("merge-base #{@head} #{full}") == full then
703
+ return true
704
+ end
705
+
706
+ # Tag is not in history, ignore
707
+ return false
708
+ end
709
+
710
+ # Cherry-pick a single commit, trying other stable alternatives if it fails.
711
+ #
712
+ # @param commit [String] Commit SHA to cherry-pick
713
+ # @raise [CherryPickErrorException] If cherry-picking the commit (and its alternatives) fails
714
+ def pick_one(commit)
715
+ cpCmd="cherry-pick --strategy=recursive -Xpatience -x"
716
+ runGitInteractive("#{cpCmd} #{commit} &> /dev/null", {}, false)
717
+ return if $? == 0
718
+
719
+ if runGit("status -uno --porcelain | wc -l") == "0" then
720
+ runGit("reset --hard")
721
+ raise CherryPickErrorException.new("Failed to cherry pick commit #{commit}", commit)
722
+ end
723
+ runGit("reset --hard")
724
+
725
+ # That didn't work? Let's try that with every variation of the commit
726
+ # in other stable trees.
727
+ @repo.find_alts(commit).each(){|alt_commit|
728
+ runGitInteractive("#{cpCmd} #{alt_commit} &> /dev/null", {}, false)
729
+ if $? == 0 then
730
+ return
731
+ end
732
+ runGit("reset --hard")
733
+ }
734
+
735
+ # Still no? Let's go back to the original commit and hand it off to
736
+ # the user.
737
+ runGitInteractive("#{cpCmd} #{commit} &> /dev/null", {}, false)
738
+ raise CherryPickErrorException.new("Failed to cherry pick commit #{commit}", commit)
739
+ end
740
+
741
+ # Handle user interaction to fix cherry-pick conflicts via an interactive shell.
742
+ #
743
+ # @param opts [Hash] Options hash
744
+ # @param commit [String] Commit SHA with conflicts
745
+ # @raise [CPAbort] If cherry-pick is aborted by the user
746
+ # @raise [CPSkip] If the user skips this commit
747
+ def cp_fix(opts, commit)
748
+ runGitInteractive("diff")
749
+ log( :INFO, "Entering subshell to fix conflicts. Exit when done")
750
+ runSystem("PS1_WARNING='CP FIX' bash", false)
751
+ rep = confirm(opts, "continue with scp [y(es), n(o), s(kip)]?", true, ["y", "n", "s"])
752
+ case rep
753
+ when "n"
754
+ runGitInteractive("cherry-pick --abort")
755
+ raise(CPAbort)
756
+ when "s"
757
+ runGitInteractive("cherry-pick --abort")
758
+ e = CPSkip.new(commit.to_s())
759
+ log(:INFO, e.to_s())
760
+ raise(e)
761
+ end
762
+ end
763
+
764
+ # Steal/cherry-pick a single commit from upstream, asking for confirmation if needed.
765
+ #
766
+ # @param opts [Hash] Options hash
767
+ # @param commit [String] Commit SHA to cherry-pick
768
+ # @param mainline [Boolean] Whether to treat this as a mainline cherry-pick (skips mapping checks)
769
+ # @raise [CPSkip] If the user chooses to skip this commit
770
+ # @raise [CPAbort] If the user chooses to abort cherry-picking
771
+ def steal_one(opts, commit, mainline=false)
772
+ msg=''
773
+ orig_cmt=commit
774
+
775
+ if mainline == false then
776
+ subj=@repo.getCommitSubj(commit)
777
+ subj.gsub!(/"/, '\"')
778
+ # Let's grab the mainline commit id, this is useful if the version tag
779
+ # doesn't exist in the commit we're looking at but exists upstream.
780
+ orig_cmt=runGit("log --no-merges --format=\"%H\" -F --grep \"#{subj}\" " +
781
+ "#{@stable_base}..origin/master | tail -n1")
782
+
783
+ if orig_cmt == "" then
784
+ log(:WARNING, "Could not find commit #{commit} in mainline")
785
+ end
786
+ end
787
+ # If the commit doesn't apply for us, skip it
788
+ if is_relevant?(orig_cmt) != true
789
+ return
790
+ end
791
+
792
+ log(:VERBOSE, "Found relevant commit #{@repo.getCommitHeadline(commit)}")
793
+ if is_in_tree?(orig_cmt) == true
794
+ # Commit is already in the stable branch, skip
795
+ log(:VERBOSE, "Commit is already in tree")
796
+ return
797
+ end
798
+
799
+ # Check if it's not blacklisted by a git-notes
800
+ if is_blacklisted?(orig_cmt) == true then
801
+ # Commit is blacklisted
802
+ log(:INFO, "Skipping 'blacklisted' commit " +
803
+ @repo.getCommitHeadline(orig_cmt))
804
+ return
805
+ end
806
+
807
+ commit_desc = @repo.getCommitHeadline(commit)
808
+ rep = "t"
809
+ while rep != "y"
810
+ rep = confirm(opts, "pick commit '#{commit_desc}' up ([y]es, [n]o, [b]lacklist)",
811
+ false, ["y", "n", "?", "b"])
812
+
813
+ case rep
814
+ when "y"
815
+ break
816
+ when "n"
817
+ raise CPSkip.new(commit)
818
+ when "b"
819
+ log(:INFO, "Blacklisting this commit for the current branch")
820
+ add_blacklist(commit)
821
+ raise CPSkip.new(commit)
822
+ when "?"
823
+ runGitInteractive("show #{commit}", {}, false)
824
+ end
825
+ end
826
+
827
+ prev_head=runGit("rev-parse HEAD")
828
+ begin
829
+ pick_one(commit)
830
+ rescue CherryPickErrorException
831
+ cp_fix(opts, commit)
832
+ end
833
+ new_head=runGit("rev-parse HEAD")
834
+
835
+ # If we didn't find the commit upstream then this must be a custom commit
836
+ # in the given tree - make sure the user checks this commit.
837
+ if orig_cmt == "" then
838
+ msg="Custom"
839
+ orig_cmt=runGit("rev-parse HEAD")
840
+ log(:WARNING, "Custom commit, please double-check!")
841
+ runBash("PS1_WARNING='CHECK'")
842
+ end
843
+ if new_head != prev_head
844
+ make_pretty(orig_cmt, msg)
845
+ end
846
+ return
847
+ end
848
+
849
+ # Steal/cherry-pick all upstream commits in the given revision range.
850
+ #
851
+ # @param opts [Hash] Options hash
852
+ # @param range [String] Git revision range (e.g., 'base..HEAD')
853
+ # @param mainline [Boolean] Whether to treat commits as mainline cherry-picks
854
+ # @raise [CPSkip] If any cherry-pick of a patch is skipped by the user
855
+ # @raise [CPAbort] If cherry-pick is aborted by the user
856
+ def steal_all(opts, range, mainline = false)
857
+ skipped = []
858
+ runGit("log --no-merges --format=\"%H\" #{range} | tac").split("\n").each(){|commit|
859
+ begin
860
+ steal_one(opts, commit, mainline)
861
+ rescue CPSkip => e
862
+ log(:INFO, e.message)
863
+ skipped << commit
864
+ rescue CPAbort => e
865
+ log(:INFO, "Cherry-pick aborted by user.")
866
+ raise e
867
+ end
868
+ }
869
+ if skipped.length > 0
870
+ raise CPSkip.new(skipped.join(" "))
871
+ end
872
+ return
873
+ end
874
+
875
+ def same_sha?(ref1, ref2)
876
+ begin
877
+ c1=@repo.ref_exist?(ref1)
878
+ c2=@repo.ref_exist?(ref2)
879
+ return c1 == c2
880
+ rescue
881
+ return false
882
+ end
883
+ end
884
+
885
+
886
+ # Add files and commit them to the release branch.
887
+ #
888
+ # @param opts [Hash] Options hash
889
+ # @param filelist [Array<String>] List of file paths to add
890
+ # @param commit_path [String, nil] Path to a file containing the commit message
891
+ # @param commit_msg [String, nil] Direct commit message text
892
+ # @raise [MissingArgumentError] If both commit_msg and commit_path are nil
893
+ # @raise [GitMaintainError] If the git commit fails
894
+ def release_do_add_commit(opts, filelist, commit_path, commit_msg=nil)
895
+ edit_flag = ""
896
+ edit_flag = "--edit" if opts[:no_edit] == false
897
+
898
+ raise MissingArgumentError.new("commit message/path") if commit_path == nil && commit_msg == nil
899
+ commit_flag=""
900
+ if commit_msg != nil
901
+ commit_flag = "-m '#{commit_msg}'"
902
+ else
903
+ commit_flag = "-F '#{commit_path}'"
904
+ end
905
+
906
+ # Add and commit
907
+ begin
908
+ runGit("add " + filelist.join(" "))
909
+ runGitInteractive("commit #{commit_flag} --verbose #{edit_flag} --signoff")
910
+ rescue RunError
911
+ raise GitMaintainError.new("Failed to commit on branch #{@local_branch}")
912
+ end
913
+ end
914
+
915
+ # Create a signed annotated release tag.
916
+ #
917
+ # @param opts [Hash] Options hash
918
+ # @param version [String] The version string/tag name
919
+ # @param tag_path [String] Path to file containing the tag message
920
+ # @raise [GitMaintainError] If tagging fails
921
+ def release_do_tag(opts, version, tag_path)
922
+ edit_flag = ""
923
+ edit_flag = "--edit" if opts[:no_edit] == false
924
+ begin
925
+ runGitInteractive("tag -a -s #{version} #{edit_flag} -F #{tag_path}")
926
+ rescue RunError
927
+ raise GitMaintainError.new("Failed to tag branch #{@local_branch}")
928
+ end
929
+ end
930
+
931
+ # Add, commit, and tag files for a release.
932
+ #
933
+ # @param opts [Hash] Options hash
934
+ # @param filelist [Array<String>] List of file paths to add and commit
935
+ # @param version [String] The version string/tag name
936
+ # @param message_path [String] Path to file containing the commit/tag message
937
+ # @raise [GitMaintainError] If committing or tagging fails
938
+ def release_do_add_commit_tag(opts, filelist, version, message_path)
939
+ release_do_add_commit(opts, filelist, message_path)
940
+ release_do_tag(opts, version, message_path)
941
+ end
942
+
943
+ end
944
+ end