git-maintain 0.12.0 → 0.14.2

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.
@@ -1,28 +1,27 @@
1
+ # Main module for git-maintain repository maintenance tool.
1
2
  module GitMaintain
2
3
 
3
- class CherryPickErrorException < StandardError
4
- def initialize(str, commit)
5
- @commit = commit
6
- super(str)
7
- end
8
- attr_reader :commit
9
- end
10
-
11
- class Branch
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.
12
7
  ACTION_LIST = [
13
8
  :cp, :steal, :list,
14
9
  :merge, :pull, :push, :monitor,
15
10
  :release, :reset, :create, :delete
16
11
  ]
12
+ # Actions that do not require updating the remote repository first.
17
13
  NO_FETCH_ACTIONS = [
18
14
  :cp, :merge, :monitor, :release, :delete
19
15
  ]
16
+ # Actions that do not require checking out the local branch before running.
20
17
  NO_CHECKOUT_ACTIONS = [
21
18
  :create, :delete, :list, :push, :monitor
22
19
  ]
20
+ # Actions that run on all branches, regardless of target versions.
23
21
  ALL_BRANCHES_ACTIONS = [
24
22
  :create
25
23
  ]
24
+ # Description map of actions for CLI help output.
26
25
  ACTION_HELP = {
27
26
  :cp => "Backport commits and eventually push them to github",
28
27
  :create => "Create missing local branches from all the stable branches",
@@ -36,16 +35,16 @@ module GitMaintain
36
35
  :release => "Create new release on all concerned branches",
37
36
  :reset => "Reset branch against upstream",
38
37
  }
39
-
40
- def self.load(repo, version, ci, branch_suff)
41
- repo_name = File.basename(repo.path)
42
- return GitMaintain::loadClass(Branch, repo_name, repo, version, ci, branch_suff)
43
- end
44
-
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
45
43
  def self.set_opts(action, optsParser, opts)
46
44
  opts[:base_ver] = 0
47
45
  opts[:version] = []
48
46
  opts[:commits] = []
47
+ opts[:breaker] = nil
49
48
  opts[:do_merge] = false
50
49
  opts[:push_force] = false
51
50
  opts[:no_ci] = false
@@ -79,6 +78,8 @@ module GitMaintain
79
78
  optsParser.banner += "-c <sha1> [-c <sha1> ...]"
80
79
  optsParser.on("-c", "--sha1 [SHA1]", String, "Commit to cherry-pick. Can be used multiple time.") {
81
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}
82
83
  when :delete
83
84
  optsParser.on("--remote", "Delete the remote staging branch instead of the local ones.") {
84
85
  |val| opts[:delete_remote] = true}
@@ -123,93 +124,82 @@ module GitMaintain
123
124
  end
124
125
  end
125
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
126
131
  def self.check_opts(opts)
127
132
  if opts[:action] == :release then
128
133
  if opts[:br_suff] != "master" then
129
- raise "Action #{opts[:action]} can only be done on 'master' suffixed branches"
134
+ raise InvalidArgumentError.new("Action #{opts[:action]} can only be done on 'master' suffixed branches")
130
135
  end
131
136
  end
132
137
  if opts[:action] == :delete && opts[:delete_remote] != true then
133
138
  if opts[:br_suff] == "master" then
134
- raise "Action #{opts[:action]} can NOT be done on 'master' suffixed branches"
139
+ raise InvalidArgumentError.new("Action #{opts[:action]} can NOT be done on 'master' suffixed branches")
135
140
  end
136
141
  end
137
142
  if opts[:action] == :push
138
143
  if opts[:stable] == true && opts[:push_force] == true then
139
- raise "Action push can NOT be use both --stable and --force"
144
+ raise InvalidArgumentError.new("Action push can NOT be use both --stable and --force")
140
145
  end
141
146
  end
142
147
  opts[:version] = [ /.*/ ] if opts[:version].length == 0
143
148
  end
144
149
 
145
- def self.execAction(opts, action)
146
- repo = Repo::load()
147
- ci = CI::load(repo)
148
- opts[:repo] = repo
149
- opts[:ci] = ci
150
- brClass = GitMaintain::getClass(self, repo.name)
151
-
152
- if NO_FETCH_ACTIONS.index(action) == nil && opts[:fetch] != false then
153
- GitMaintain::log(:INFO, "Fetching stable repo")
154
- repo.stableUpdate(opts[:fetch])
155
- end
156
-
157
- branchList=[]
158
- if opts[:manual_branch] == nil then
159
- unfilteredList = nil
160
- if ALL_BRANCHES_ACTIONS.index(action) != nil then
161
- unfilteredList = repo.getStableBranchList()
162
- else
163
- unfilteredList = repo.getBranchList(opts[:br_suff])
164
- end
165
- branchList = unfilteredList.map(){|br|
166
- branch = Branch::load(repo, br, ci, opts[:br_suff])
167
- case branch.is_targetted?(opts)
168
- when :too_old
169
- GitMaintain::log(:VERBOSE, "Skipping older v#{branch.version}")
170
- next
171
- when :no_match
172
- GitMaintain::log(:VERBOSE, "Skipping v#{branch.version} not matching" +
173
- opts[:version].to_s())
174
- next
175
- end
176
- branch
177
- }.compact()
178
- else
179
- branchList = [ Branch::load(repo, opts[:manual_branch], ci, opts[:br_suff]) ]
180
- end
181
-
182
- loop do
183
- system("clear; date") if opts[:watch] != false
184
-
185
- res=[]
186
-
187
- # Iterate concerned on all branches
188
- branchList.each(){|branch|
189
- if NO_CHECKOUT_ACTIONS.index(action) == nil then
190
- GitMaintain::log(:INFO, "Working on #{branch.verbose_name}")
191
- branch.checkout()
192
- end
193
- res << branch.send(action, opts)
194
- }
195
-
196
- # Run epilogue (if it exists)
197
- begin
198
- brClass.send(action.to_s() + "_epilogue", opts, res)
199
- rescue NoMethodError => e
200
- end
201
-
202
- break if opts[:watch] == false
203
- sleep(opts[:watch])
204
- ci.emptyCache()
205
- end
206
- GitMaintain::log(:INFO, "Done working on selected branches")
207
-
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)
208
161
  end
209
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
210
199
  def initialize(repo, version, ci, branch_suff)
211
200
  GitMaintain::checkDirectConstructor(self.class)
212
201
 
202
+ @path = repo.path
213
203
  @repo = repo
214
204
  @ci = ci
215
205
  @version = version
@@ -229,7 +219,7 @@ module GitMaintain
229
219
  @head = @repo.ref_exist?(@local_branch)
230
220
  @valid_ref = "#{@repo.valid_repo}/#{@local_branch}"
231
221
  @remote_ref = "#{@repo.stable_repo}/#{@remote_branch}"
232
- @stable_head =
222
+ @stable_head =
233
223
  begin
234
224
  @repo.ref_exist?(@remote_ref)
235
225
  rescue
@@ -245,10 +235,11 @@ module GitMaintain
245
235
  attr_reader :version, :local_branch, :head, :remote_branch, :valid_ref, :remote_ref, :stable_head,
246
236
  :verbose_name, :exists, :stable_base
247
237
 
248
- def log(lvl, str)
249
- GitMaintain::log(lvl, str)
250
- end
251
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
252
243
  def is_targetted?(opts)
253
244
  return true if @branch_type == :user_specified
254
245
  if @version.to_i < opts[:base_ver] then
@@ -260,27 +251,44 @@ module GitMaintain
260
251
  return :no_match
261
252
  end
262
253
 
263
- # Checkout the repo to the given branch
254
+ # Checkout the git repository to this branch's local branch.
255
+ #
256
+ # @raise [RunError] If git checkout execution fails
264
257
  def checkout()
265
- begin
266
- print @repo.runGit("checkout -q #{@local_branch}")
267
- rescue RuntimeError => e
268
- crit("Failed to checkout the branch #{@local_branch}")
269
- end
258
+ runGitInteractive("checkout -q #{@local_branch}")
270
259
  end
271
260
 
272
- # Cherry pick an array of commits
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
273
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
274
272
  opts[:commits].each(){|commit|
275
- prev_head=@repo.runGit("rev-parse HEAD")
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")
276
278
  log(:INFO, "Applying #{@repo.getCommitHeadline(commit)}")
277
279
  begin
278
- @repo.runGitInteractive("cherry-pick #{commit}")
279
- rescue RuntimeError
280
- log(:WARNING, "Cherry pick failure. Starting bash for manual fixes. Exit shell to continue")
281
- @repo.runBash("PS1_WARNING='CP FIX'")
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
282
290
  end
283
- new_head=@repo.runGit("rev-parse HEAD")
291
+ new_head=runGit("rev-parse HEAD")
284
292
  # Do not make commit pretty if it was not applied
285
293
  if new_head != prev_head
286
294
  make_pretty(commit)
@@ -288,7 +296,11 @@ module GitMaintain
288
296
  }
289
297
  end
290
298
 
291
- # Steal upstream commits that are not in the branch
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
292
304
  def steal(opts)
293
305
  base_ref=@stable_base
294
306
 
@@ -297,51 +309,61 @@ module GitMaintain
297
309
  case opts[:steal_base]
298
310
  when nil
299
311
  begin
300
- sha = @repo.runGit("rev-parse 'git-maintain/steal/last/#{@stable_base}' 2>&1")
312
+ sha = runGit("rev-parse 'git-maintain/steal/last/#{@stable_base}' 2>&1")
301
313
  base_ref=sha
302
314
  log(:VERBOSE, "Starting from last successfull run:")
303
315
  log(:VERBOSE, @repo.getCommitHeadline(base_ref))
304
- rescue RuntimeError
316
+ rescue RunError
305
317
  # No matching tag found. Not an issue
306
318
  end
307
319
  when :all
308
320
  base_ref=@stable_base
309
321
  else
310
322
  begin
311
- sha = @repo.runGit("rev-parse #{opts[:steal_base]} 2>&1")
323
+ sha = runGit("rev-parse #{opts[:steal_base]} 2>&1")
312
324
  base_ref=sha
313
325
  log(:VERBOSE, "Starting from base:")
314
326
  log(:VERBOSE, @repo.getCommitHeadline(base_ref))
315
- rescue RuntimeError
327
+ rescue RunError
316
328
  crit("Could not find specified base '#{opts[:steal_base]}'")
317
329
  end
318
330
  end
319
331
 
320
- master_sha=@repo.runGit("rev-parse origin/master")
321
- res = steal_all(opts, "#{base_ref}..#{master_sha}", true)
332
+ master_sha=runGit("rev-parse origin/master")
322
333
 
323
- # If we picked all the commits (or nothing happened)
324
- # Mark the current master as the last checked point so we
325
- # can just steal from this point on the next run
326
- if res == true then
327
- @repo.runGit("tag -f 'git-maintain/steal/last/#{@stable_base}' origin/master")
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")
328
341
  log(:VERBOSE, "Marking new last successfull run at:")
329
342
  log(:VERBOSE, @repo.getCommitHeadline(master_sha))
343
+ rescue CPSkip
344
+ # Ignore the error
330
345
  end
331
346
  end
332
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
333
352
  def list(opts)
334
- GitMaintain::log(:INFO, "Working on #{@verbose_name}")
353
+ log(:INFO, "Working on #{@verbose_name}")
335
354
  if opts[:stable] == true then
336
355
  # List commits in the stable_branch that are no in the latest release
337
- GitMaintain::showLog(opts, @remote_ref, @repo.runGit("describe --abbrev=0 #{@local_branch}"))
356
+ showLog(opts, @remote_ref, runGit("describe --abbrev=0 #{@local_branch}"))
338
357
  else
339
358
  # List commits in the branch that are no in the stable branch
340
- GitMaintain::showLog(opts, @local_branch, @remote_ref)
359
+ showLog(opts, @local_branch, @remote_ref)
341
360
  end
342
361
  end
343
362
 
344
- # Merge merge_branch into this one
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
345
367
  def merge(opts)
346
368
  merge_branch = @repo.versionToLocalBranch(@version, opts[:do_merge])
347
369
 
@@ -354,26 +376,31 @@ module GitMaintain
354
376
  end
355
377
 
356
378
  # See if there is anything worth merging
357
- merge_base_hash = @repo.runGit("merge-base #{merge_branch} #{@local_branch}")
379
+ merge_base_hash = runGit("merge-base #{merge_branch} #{@local_branch}")
358
380
  if merge_base_hash == hash_to_merge then
359
381
  log(:INFO, "Branch #{merge_branch} has no commit that needs to be merged")
360
382
  return
361
383
  end
362
384
 
363
- rep = GitMaintain::checkLog(opts, merge_branch, @local_branch, "merge")
385
+ rep = checkLog(opts, merge_branch, @local_branch, "merge")
364
386
  if rep == "y" then
365
387
  begin
366
- @repo.runGitInteractive("merge #{merge_branch}")
367
- rescue RuntimeError
388
+ runGitInteractive("merge #{merge_branch}")
389
+ rescue RunError
368
390
  log(:WARNING, "Merge failure. Starting bash for manual fixes. Exit shell to continue")
369
- @repo.runBash("PS1_WARNING='MERGING'")
391
+ runBash("PS1_WARNING='MERGING'")
370
392
  end
371
393
  else
372
394
  log(:INFO, "Skipping merge")
373
395
  return
374
- end
396
+ end
375
397
  end
376
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
377
404
  def pull(opts)
378
405
  remoteRef = opts[:stable] == true ? @remote_ref : @valid_ref
379
406
 
@@ -384,10 +411,14 @@ module GitMaintain
384
411
  log(:INFO, "Branch #{remoteRef} does not exists. Skipping...")
385
412
  return
386
413
  end
387
- @repo.runGitInteractive("rebase #{remoteRef}")
414
+ runGitInteractive("rebase #{remoteRef}")
388
415
 
389
416
  end
390
- # Push the branch to the validation repo
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
391
422
  def push(opts)
392
423
  remoteRef = opts[:stable] == true ? @remote_ref : @valid_ref
393
424
 
@@ -409,35 +440,47 @@ module GitMaintain
409
440
  end
410
441
 
411
442
  if opts[:check_only] == true then
412
- GitMaintain::checkLog(opts, @local_branch, @remote_ref, "")
443
+ checkLog(opts, @local_branch, @remote_ref, "")
413
444
  return
414
445
  end
415
446
 
416
447
  # For validation/CI push, let's go and push already
417
- return "#{@local_branch}:#{@local_branch}" if opts[:stable] != true
448
+ if opts[:stable] != true
449
+ opts[:push_branches] ||= []
450
+ opts[:push_branches] << "#{@local_branch}:#{@local_branch}"
451
+ return
452
+ end
418
453
 
419
454
  # For stable, we need to confirm with the user that he really wants to push
420
- rep = GitMaintain::checkLog(opts, @local_branch, @remote_ref, "submit")
455
+ rep = checkLog(opts, @local_branch, @remote_ref, "submit")
421
456
  if rep == "y" then
422
- return "#{@local_branch}:#{@remote_branch}"
457
+ opts[:push_branches] ||= []
458
+ opts[:push_branches] << "#{@local_branch}:#{@remote_branch}"
423
459
  else
424
460
  log(:INFO, "Skipping push to stable")
425
461
  return
426
462
  end
427
463
  end
428
464
 
429
- def self.push_epilogue(opts, branches)
430
- # Compact to remove empty entries
431
- branches.compact!()
432
-
433
- return if branches.length == 0
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
434
473
 
435
474
  repo = (opts[:stable] == true) ? opts[:repo].stable_repo : opts[:repo].valid_repo
436
475
  opts[:repo].runGit("push #{opts[:push_force] == true ? "-f" : ""} "+
437
- "#{repo} #{branches.join(" ")}")
476
+ "#{repo} #{push_list.join(" ")}")
438
477
  end
439
478
 
440
- # Monitor the build status on CI
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
441
484
  def monitor(opts)
442
485
  ts = st = head = nil
443
486
  suff=""
@@ -458,7 +501,7 @@ module GitMaintain
458
501
  rep = "y"
459
502
  suff=""
460
503
  while rep == "y"
461
- rep = GitMaintain::confirm(opts, "see the build log#{suff}")
504
+ rep = confirm(opts, "see the build log#{suff}")
462
505
  if rep == "y" then
463
506
  log = @ci.getValidLog(self, @head)
464
507
  tmp = `mktemp`.chomp()
@@ -473,32 +516,47 @@ module GitMaintain
473
516
  end
474
517
  end
475
518
 
476
- # Reset the branch to the upstream stable one
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
477
523
  def reset(opts)
478
524
  if same_sha?(@local_branch, @remote_ref) then
479
525
  log(:INFO, "Nothing to reset")
480
526
  return
481
527
  end
482
528
 
483
- rep = GitMaintain::checkLog(opts, @local_branch, @remote_ref, "reset")
529
+ rep = checkLog(opts, @local_branch, @remote_ref, "reset")
484
530
  if rep == "y" then
485
- @repo.runGit("reset --hard #{@remote_ref}")
531
+ runGit("reset --hard #{@remote_ref}")
486
532
  else
487
533
  log(:INFO, "Skipping reset")
488
534
  return
489
535
  end
490
536
  end
491
537
 
538
+ # Create a release on the current branch (dummy/unsupported method for the base Branch class).
539
+ #
540
+ # @param opts [Hash] Options hash
492
541
  def release(opts)
493
542
  log(:ERROR,"#No release command available for this repo")
494
543
  end
495
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
496
549
  def create(opts)
497
550
  return if @head != ""
498
551
  log(:INFO, "Creating missing #{@local_branch} from #{@remote_ref}")
499
- @repo.runGit("branch #{@local_branch} #{@remote_ref}")
552
+ runGit("branch #{@local_branch} #{@remote_ref}")
500
553
  end
501
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
502
560
  def delete(opts)
503
561
  if opts[:delete_remote] == true then
504
562
  begin
@@ -511,40 +569,49 @@ module GitMaintain
511
569
  else
512
570
  msg = "delete branch #{@local_branch}"
513
571
  end
514
- rep = GitMaintain::confirm(opts, msg)
572
+ rep = confirm(opts, msg)
515
573
  if rep == "y" then
516
- return @local_branch
574
+ opts[:delete_branches] ||= []
575
+ opts[:delete_branches] << @local_branch
517
576
  else
518
577
  log(:INFO, "Skipping deletion")
519
578
  return
520
579
  end
521
580
  end
522
- def self.delete_epilogue(opts, branches)
523
- # Compact to remove empty entries
524
- branches.compact!()
525
581
 
526
- return if branches.length == 0
527
- puts "Deleting #{opts[:delete_remote] == true ? "remote" : "local"} branches: #{branches.join(" ")}"
528
- rep = GitMaintain::confirm(opts, "continue", true)
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)
529
592
  if rep != "y" then
530
593
  log(:INFO, "Cancelling")
531
594
  return
532
595
  end
533
596
  if opts[:delete_remote] == true then
534
- opts[:repo].runGit("push #{opts[:repo].valid_repo} #{branches.map(){|x| ":" + x}.join(" ")}")
597
+ opts[:repo].runGit("push #{opts[:repo].valid_repo} #{delete_list.map(){|x| ":" + x}.join(" ")}")
535
598
  else
536
- opts[:repo].runGit("branch -D #{branches.join(" ")}")
599
+ opts[:repo].runGit("branch -D #{delete_list.join(" ")}")
537
600
  end
538
601
  end
539
602
 
540
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
541
608
  def add_blacklist(commit)
542
- @repo.runGit("notes append -m \"#{@local_branch}\" #{commit}")
609
+ runGit("notes append -m \"#{@local_branch}\" #{commit}")
543
610
  end
544
611
 
545
612
  def is_blacklisted?(commit)
546
613
  begin
547
- @repo.runGit("notes show #{commit} 2> /dev/null").split("\n").each(){|br|
614
+ runGit("notes show #{commit} 2> /dev/null").split("\n").each(){|br|
548
615
  return true if br == @local_branch
549
616
  }
550
617
  rescue
@@ -552,15 +619,21 @@ module GitMaintain
552
619
  return false
553
620
  end
554
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
555
628
  def make_pretty(orig_commit, commit="")
556
- orig_sha=@repo.runGit("rev-parse #{orig_commit}")
629
+ orig_sha=runGit("rev-parse #{orig_commit}")
557
630
  msg_commit = (commit.to_s() == "") ? orig_sha : commit
558
631
 
559
632
  msg_path=`mktemp`.chomp()
560
633
  msg_file = File.open(msg_path, "w+")
561
- msg_file.puts @repo.runGit("log -1 --format=\"%s%n%n[ Upstream commit #{msg_commit} ]%n%n%b\" #{orig_commit}")
634
+ msg_file.puts runGit("log -1 --format=\"%s%n%n[ Upstream commit #{msg_commit} ]%n%n%b\" #{orig_commit}")
562
635
  msg_file.close()
563
- @repo.runGit("commit -s --amend -F #{msg_path}")
636
+ runGit("commit -s --amend -F #{msg_path}")
564
637
  `rm -f #{msg_path}`
565
638
  end
566
639
 
@@ -576,7 +649,7 @@ module GitMaintain
576
649
  end
577
650
 
578
651
  # Hope for the best, same commit is/isn't in the current branch
579
- if @repo.runGit("merge-base #{fullhash} HEAD") == fullhash then
652
+ if runGit("merge-base #{fullhash} HEAD") == fullhash then
580
653
  return true
581
654
  end
582
655
 
@@ -585,10 +658,10 @@ module GitMaintain
585
658
  subj=@repo.getCommitSubj(commit)
586
659
 
587
660
  # Try and find if there's a commit with given subject the hard way
588
- @repo.runGit("log --pretty=\"%H\" -F --grep \"#{subj.gsub("\"", '\\"')}\" "+
661
+ runGit("log --pretty=\"%H\" -F --grep \"#{subj.gsub("\"", '\\"')}\" "+
589
662
  "#{@stable_base}..HEAD").split("\n").each(){|cmt|
590
- cursubj=@repo.runGit("log -1 --format=\"%s\" #{cmt}")
591
- if cursubj = subj then
663
+ cursubj=runGit("log -1 --format=\"%s\" #{cmt}")
664
+ if cursubj == subj then
592
665
  return true
593
666
  end
594
667
  }
@@ -597,7 +670,7 @@ module GitMaintain
597
670
 
598
671
  def is_relevant?(commit)
599
672
  # Let's grab the commit that this commit fixes (if exists (based on the "Fixes:" tag)).
600
- fixescmt=@repo.runGit("log -1 #{commit} | grep -i \"fixes:\" | head -n 1 | "+
673
+ fixescmt=runGit("log -1 #{commit} | grep -i \"fixes:\" | head -n 1 | "+
601
674
  "sed -e 's/^[ \\t]*//' | cut -f 2 -d ':' | "+
602
675
  "sed -e 's/^[ \\t]*//' -e 's/\\([0-9a-f]\\+\\)(/\\1 (/' | cut -f 1 -d ' '")
603
676
 
@@ -611,22 +684,22 @@ module GitMaintain
611
684
  end
612
685
  end
613
686
 
614
- if @repo.runGit("show #{commit} | grep -i 'stable@' | wc -l") == "0" then
687
+ if runGit("show #{commit} | grep -i 'stable@' | wc -l") == "0" then
615
688
  return false
616
689
  end
617
690
 
618
691
  # Let's see if there's a version tag in this commit
619
- full=@repo.runGit("show #{commit} | grep -i 'stable@'").gsub(/.* #?/, "")
692
+ full=runGit("show #{commit} | grep -i 'stable@'").gsub(/.* #?/, "")
620
693
 
621
694
  # Sanity check our extraction
622
695
  if full =~ /stable/ then
623
696
  return false
624
697
  end
625
698
 
626
- full = @repo.runGit("rev-parse #{full}^{commit}")
699
+ full = runGit("rev-parse #{full}^{commit}")
627
700
 
628
701
  # Make sure our branch contains this version
629
- if @repo.runGit("merge-base #{@head} #{full}") == full then
702
+ if runGit("merge-base #{@head} #{full}") == full then
630
703
  return true
631
704
  end
632
705
 
@@ -634,73 +707,67 @@ module GitMaintain
634
707
  return false
635
708
  end
636
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
637
714
  def pick_one(commit)
638
715
  cpCmd="cherry-pick --strategy=recursive -Xpatience -x"
639
- @repo.runGitInteractive("#{cpCmd} #{commit} &> /dev/null", :check_err => false)
716
+ runGitInteractive("#{cpCmd} #{commit} &> /dev/null", {}, false)
640
717
  return if $? == 0
641
718
 
642
- if @repo.runGit("status -uno --porcelain | wc -l") == "0" then
643
- @repo.runGit("reset --hard")
719
+ if runGit("status -uno --porcelain | wc -l") == "0" then
720
+ runGit("reset --hard")
644
721
  raise CherryPickErrorException.new("Failed to cherry pick commit #{commit}", commit)
645
722
  end
646
- @repo.runGit("reset --hard")
723
+ runGit("reset --hard")
647
724
 
648
725
  # That didn't work? Let's try that with every variation of the commit
649
726
  # in other stable trees.
650
727
  @repo.find_alts(commit).each(){|alt_commit|
651
- @repo.runGitInteractive("#{cpCmd} #{alt_commit} &> /dev/null", :check_err => false)
728
+ runGitInteractive("#{cpCmd} #{alt_commit} &> /dev/null", {}, false)
652
729
  if $? == 0 then
653
730
  return
654
731
  end
655
- @repo.runGit("reset --hard")
732
+ runGit("reset --hard")
656
733
  }
657
734
 
658
735
  # Still no? Let's go back to the original commit and hand it off to
659
736
  # the user.
660
- @repo.runGitInteractive("#{cpCmd} #{commit} &> /dev/null", :check_err => false)
737
+ runGitInteractive("#{cpCmd} #{commit} &> /dev/null", {}, false)
661
738
  raise CherryPickErrorException.new("Failed to cherry pick commit #{commit}", commit)
662
739
  end
663
740
 
664
- def confirm_one(opts, commit)
665
- rep=""
666
- do_cp=false
667
- puts @repo.getCommitHeadline(commit)
668
- while rep != "y" do
669
- puts "Do you want to steal this commit ? (y/n/b/?)"
670
- case opts[:yn_default]
671
- when :no
672
- log(:INFO, "Auto-replying no due to --no option")
673
- rep = 'n'
674
- break
675
- when :yes
676
- log(:INFO, "Auto-replying yes due to --yes option")
677
- rep = 'y'
678
- else
679
- rep = STDIN.gets.chomp()
680
- end
681
-
682
- case rep
683
- when "n"
684
- log(:INFO, "Skip this commit")
685
- break
686
- when "b"
687
- log(:INFO, "Blacklisting this commit for the current branch")
688
- add_blacklist(commit)
689
- break
690
- when "y"
691
- rep="y"
692
- do_cp=true
693
- break
694
- when "?"
695
- puts @repo.runGit("show #{commit}")
696
- else
697
- log(:ERROR, "Invalid answer $rep")
698
- puts @repo.runGit("show --format=oneline --no-patch --no-decorate #{commit}")
699
- end
700
- end
701
- return do_cp
702
- end
703
-
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
704
771
  def steal_one(opts, commit, mainline=false)
705
772
  msg=''
706
773
  orig_cmt=commit
@@ -710,7 +777,7 @@ module GitMaintain
710
777
  subj.gsub!(/"/, '\"')
711
778
  # Let's grab the mainline commit id, this is useful if the version tag
712
779
  # doesn't exist in the commit we're looking at but exists upstream.
713
- orig_cmt=@repo.runGit("log --no-merges --format=\"%H\" -F --grep \"#{subj}\" " +
780
+ orig_cmt=runGit("log --no-merges --format=\"%H\" -F --grep \"#{subj}\" " +
714
781
  "#{@stable_base}..origin/master | tail -n1")
715
782
 
716
783
  if orig_cmt == "" then
@@ -719,14 +786,14 @@ module GitMaintain
719
786
  end
720
787
  # If the commit doesn't apply for us, skip it
721
788
  if is_relevant?(orig_cmt) != true
722
- return true
789
+ return
723
790
  end
724
791
 
725
792
  log(:VERBOSE, "Found relevant commit #{@repo.getCommitHeadline(commit)}")
726
793
  if is_in_tree?(orig_cmt) == true
727
794
  # Commit is already in the stable branch, skip
728
795
  log(:VERBOSE, "Commit is already in tree")
729
- return true
796
+ return
730
797
  end
731
798
 
732
799
  # Check if it's not blacklisted by a git-notes
@@ -734,41 +801,75 @@ module GitMaintain
734
801
  # Commit is blacklisted
735
802
  log(:INFO, "Skipping 'blacklisted' commit " +
736
803
  @repo.getCommitHeadline(orig_cmt))
737
- return true
804
+ return
738
805
  end
739
806
 
740
- do_cp = confirm_one(opts, orig_cmt)
741
- return false if do_cp != true
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"])
742
812
 
743
- prev_head=@repo.runGit("rev-parse HEAD")
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
744
826
 
827
+ prev_head=runGit("rev-parse HEAD")
745
828
  begin
746
829
  pick_one(commit)
747
- rescue CherryPickErrorException => e
748
- log(:WARNING, "Cherry pick failed. Fix, commit (or reset) and exit.")
749
- @repo.runBash("PS1_WARNING='CP FIX'")
830
+ rescue CherryPickErrorException
831
+ cp_fix(opts, commit)
750
832
  end
751
- new_head=@repo.runGit("rev-parse HEAD")
833
+ new_head=runGit("rev-parse HEAD")
752
834
 
753
835
  # If we didn't find the commit upstream then this must be a custom commit
754
836
  # in the given tree - make sure the user checks this commit.
755
837
  if orig_cmt == "" then
756
838
  msg="Custom"
757
- orig_cmt=@repo.runGit("rev-parse HEAD")
839
+ orig_cmt=runGit("rev-parse HEAD")
758
840
  log(:WARNING, "Custom commit, please double-check!")
759
- @repo.runBash("PS1_WARNING='CHECK'")
841
+ runBash("PS1_WARNING='CHECK'")
760
842
  end
761
843
  if new_head != prev_head
762
844
  make_pretty(orig_cmt, msg)
763
845
  end
846
+ return
764
847
  end
765
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
766
856
  def steal_all(opts, range, mainline = false)
767
- res = true
768
- @repo.runGit("log --no-merges --format=\"%H\" #{range} | tac").split("\n").each(){|commit|
769
- res &= steal_one(opts, commit, mainline)
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
770
868
  }
771
- return res
869
+ if skipped.length > 0
870
+ raise CPSkip.new(skipped.join(" "))
871
+ end
872
+ return
772
873
  end
773
874
 
774
875
  def same_sha?(ref1, ref2)
@@ -782,11 +883,19 @@ module GitMaintain
782
883
  end
783
884
 
784
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
785
894
  def release_do_add_commit(opts, filelist, commit_path, commit_msg=nil)
786
895
  edit_flag = ""
787
896
  edit_flag = "--edit" if opts[:no_edit] == false
788
897
 
789
- raise("No commit message provided") if commit_path == nil && commit_msg == nil
898
+ raise MissingArgumentError.new("commit message/path") if commit_path == nil && commit_msg == nil
790
899
  commit_flag=""
791
900
  if commit_msg != nil
792
901
  commit_flag = "-m '#{commit_msg}'"
@@ -796,29 +905,39 @@ module GitMaintain
796
905
 
797
906
  # Add and commit
798
907
  begin
799
- @repo.runGit("add " + filelist.join(" "))
800
- @repo.runGitInteractive("commit #{commit_flag} --verbose #{edit_flag} --signoff")
801
- rescue RuntimeError
802
- raise("Failed to commit on branch #{@local_branch}")
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}")
803
912
  end
804
- return 0
805
913
  end
806
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
807
921
  def release_do_tag(opts, version, tag_path)
808
922
  edit_flag = ""
809
923
  edit_flag = "--edit" if opts[:no_edit] == false
810
924
  begin
811
- @repo.runGitInteractive("tag -a -s #{version} #{edit_flag} -F #{tag_path}")
812
- rescue RuntimeError
813
- raise("Failed to tag branch #{@local_branch}")
925
+ runGitInteractive("tag -a -s #{version} #{edit_flag} -F #{tag_path}")
926
+ rescue RunError
927
+ raise GitMaintainError.new("Failed to tag branch #{@local_branch}")
814
928
  end
815
- return 0
816
929
  end
817
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
818
938
  def release_do_add_commit_tag(opts, filelist, version, message_path)
819
939
  release_do_add_commit(opts, filelist, message_path)
820
940
  release_do_tag(opts, version, message_path)
821
- return 0
822
941
  end
823
942
 
824
943
  end