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,52 +1,58 @@
1
1
  require 'octokit'
2
2
  require 'io/console'
3
3
 
4
+ # Main module for git-maintain repository maintenance tool.
4
5
  module GitMaintain
5
- class NoRefError < RuntimeError
6
- def initialize(ref)
7
- super("Reference '#{ref}' was not found")
8
- end
9
- end
10
- class Repo
6
+ # Class representing a git repository being maintained by git-maintain.
7
+ # Handles parsing and caching configuration, querying branches/tags, and executing git commands.
8
+ class Repo < Common
9
+ # Default name of the validation/upstream repository.
11
10
  @@VALID_REPO = "github"
11
+ # Default name of the stable release repository.
12
12
  @@STABLE_REPO = "stable"
13
+ # Default path to the release package submission helper command.
13
14
  @@SUBMIT_BINARY="git-release"
14
15
 
16
+ # List of available actions for Repo class.
15
17
  ACTION_LIST = [
16
18
  :list_branches,
17
19
  :summary,
18
20
  # Internal commands for completion
19
21
  :list_suffixes, :submit_release
20
22
  ]
23
+ # Description map of actions for CLI help output.
21
24
  ACTION_HELP = {
22
25
  :submit_release => "Push the tags to 'stable' remote and create the release packages",
23
26
  :summary => "Displays a summary of the configuration and the branches git-maintain sees"
24
27
  }
25
28
 
26
- def self.load(path=".")
27
- dir = File.realdirpath(path)
28
- repo_name = File.basename(dir)
29
- return GitMaintain::loadClass(Repo, repo_name, dir)
29
+ # Factory method to load an instance of the Repo class or its repository-specific subclass.
30
+ #
31
+ # @param path [String] Repository directory path (defaults to current working directory)
32
+ # @return [Repo] The loaded Repo instance (or subclass)
33
+ # @raise [GitMaintainError] If class loading fails
34
+ def self.load()
35
+ (repo_path, repo_name) = GitMaintain::getRepoInfos()
36
+ return GitMaintain::loadClass(Repo, repo_name, repo_path)
30
37
  end
31
38
 
39
+ # Validate parsed options for repository-level actions.
40
+ #
41
+ # @param opts [Hash] Options hash to validate
42
+ # @raise [GitMaintainError] If options are invalid or conflicting
32
43
  def self.check_opts(opts)
33
44
  if opts[:action] == :submit_release then
34
45
  if opts[:br_suff] != "master" then
35
- raise "Action #{opts[:action]} can only be done on 'master' suffixed branches"
46
+ raise GitMaintainError.new("Action #{opts[:action]} can only be done on 'master' suffixed branches")
36
47
  end
37
48
  end
38
49
  end
39
50
 
40
- def self.execAction(opts, action)
41
- repo = Repo::load()
42
-
43
- if action == :submit_release then
44
- repo.stableUpdate()
45
- end
46
- repo.send(action, opts)
47
- end
48
-
49
- def initialize(path=nil)
51
+ # Initialize the Repo instance, parsing git configurations and detecting remotes/formats.
52
+ #
53
+ # @param path [String, nil] Repository directory path (defaults to current working directory)
54
+ # @raise [GitMaintainError] If configuration format or values are invalid
55
+ def initialize(path)
50
56
  GitMaintain::checkDirectConstructor(self.class)
51
57
 
52
58
  @path = path
@@ -79,11 +85,11 @@ module GitMaintain
79
85
  when "false", "no", "off"
80
86
  @auto_fetch = false
81
87
  else
82
- raise("Invalid value '#{@auto_fetch}' in git config for maintain.autofetch")
88
+ raise GitMaintainError.new("Invalid value '#{@auto_fetch}' in git config for maintain.autofetch")
83
89
  end
84
90
 
85
91
  @branch_format_raw = getGitConfig("maintain.branch-format")
86
- @branch_format = Regexp.new(/#{@branch_format_raw}/)
92
+ @branch_format = Regexp.new(@branch_format_raw)
87
93
  @stable_branch_format = getGitConfig("maintain.stable-branch-format")
88
94
  @stable_base_format = getGitConfig("maintain.stable-base-format")
89
95
 
@@ -103,7 +109,7 @@ module GitMaintain
103
109
  case @mail_format
104
110
  when "imap_send", "send_email"
105
111
  else
106
- raise("Invalid mail-format #{@mail_format}")
112
+ raise GitMaintainError.new("Invalid mail-format #{@mail_format}")
107
113
  end
108
114
 
109
115
  @mail_format = @mail_format.to_sym()
@@ -111,47 +117,25 @@ module GitMaintain
111
117
  end
112
118
  attr_reader :path, :name, :remote_valid, :remote_stable, :valid_repo, :stable_repo
113
119
 
114
- def log(lvl, str)
115
- GitMaintain::log(lvl, str)
116
- end
117
120
 
118
- def _run_check_ret(ret, opts)
119
- raise(RuntimeError.new(ret)) if $?.exitstatus != 0 && opts.fetch(:check_err, true) == true
120
- end
121
- def run(cmd, opts = {})
122
- ret = `cd #{@path} && #{cmd}`
123
- _run_check_ret(ret, opts)
124
- return ret
125
- end
126
- def runSystem(cmd, opts = {})
127
- ret = system("cd #{@path} && #{cmd}")
128
- _run_check_ret(nil, opts)
129
- return ret
130
-
131
- end
132
- def runGit(cmd, opts = {})
133
- log(:DEBUG, "Called from #{caller[1]}")
134
- log(:DEBUG, "Running git command '#{cmd}'")
135
- ret = `git --work-tree=#{@path} #{cmd}`.chomp()
136
- _run_check_ret(ret, opts)
137
- return ret
138
- end
139
- def runGitInteractive(cmd, opts = {})
140
- log(:DEBUG, "Called from #{caller[1]}")
141
- log(:DEBUG, "Running interactive git command '#{cmd}'")
142
- ret = system("git --work-tree=#{@path} #{cmd}")
143
- _run_check_ret(nil, opts)
144
- return ret
145
121
 
146
- end
122
+ # Check if the specified git reference exists.
123
+ #
124
+ # @param ref [String] Git reference to verify (e.g. 'HEAD' or 'origin/master')
125
+ # @return [String] Resolved SHA-1 string of the reference
126
+ # @raise [NoRefError] If the reference does not exist
147
127
  def ref_exist?(ref)
148
128
  begin
149
129
  return runGit("rev-parse --verify --quiet '#{ref}'")
150
- rescue RuntimeError
130
+ rescue RunError
151
131
  raise(NoRefError.new(ref))
152
132
  end
153
133
  end
154
134
 
135
+ # Run a git imap-send command setting GIT_ASKPASS environment variables.
136
+ #
137
+ # @param cmd [String] The command arguments
138
+ # @return [String] Output of the command execution
155
139
  def runGitImap(cmd)
156
140
  return `export GIT_ASKPASS=$(dirname $(dirname $(which git)))/lib/git-core/git-gui--askpass;
157
141
  if [ ! -f $GIT_ASKPASS ]; then
@@ -161,68 +145,112 @@ module GitMaintain
161
145
  export GIT_ASKPASS=/usr/lib/ssh/ssh-askpass;
162
146
  fi; git --work-tree=#{@path} imap-send #{cmd}`
163
147
  end
148
+
149
+ # Retrieve a git configuration value, with caching.
150
+ #
151
+ # @param entry [String] Config key name (e.g., 'user.name')
152
+ # @return [String] Cached or fetched config value string
153
+ # @raise [RunError] If running git config fails unexpectedly
164
154
  def getGitConfig(entry)
165
- return @config_cache[entry] ||= runGit("config #{entry} 2> /dev/null", :check_err => false).chomp()
155
+ return @config_cache[entry] ||= runGit("config #{entry} 2> /dev/null", {}, false).chomp()
166
156
  end
167
157
 
158
+ # Spawn an interactive subshell (bash), wrapping error conditions into a GitMaintainError.
159
+ #
160
+ # @param env [String] Optional environment variables string to prepend
161
+ # @raise [GitMaintainError] If the shell exits with a non-zero code and is cancelled by the user
168
162
  def runBash(env="")
169
163
  begin
170
164
  runSystem(env + " bash")
171
- rescue RuntimeError
165
+ rescue RunError
172
166
  log(:ERROR, "Shell exited with code #{$?}. Exiting")
173
- raise("Cancelled by user")
167
+ raise GitMaintainError.new("Cancelled by user")
174
168
  end
175
169
  log(:INFO, "Continuing...")
176
170
 
177
171
  end
178
172
 
173
+ # Get the single-line headline/oneline description of a commit SHA.
174
+ #
175
+ # @param sha [String] Commit SHA
176
+ # @return [String] Single-line description (oneline)
177
+ # @raise [RunError] If git command execution fails
179
178
  def getCommitHeadline(sha)
180
179
  return runGit("show --format=oneline --no-patch --no-decorate #{sha}")
181
180
  end
181
+
182
+ # Get the commit subject text.
183
+ #
184
+ # @param sha [String] Commit SHA
185
+ # @return [String] The commit subject string
186
+ # @raise [RunError] If git command execution fails
182
187
  def getCommitSubj(sha)
183
188
  return runGit("log -1 --pretty=\"%s\" #{sha}")
184
189
  end
185
190
 
191
+ # Fetch stable updates if auto-fetching is enabled.
192
+ #
193
+ # @param fetch [Boolean, nil] Override to bypass or force fetch configuration
194
+ # @raise [RunError] If git fetch execution fails
186
195
  def stableUpdate(fetch=nil)
187
196
  fetch = @auto_fetch if fetch == nil
188
197
  return if fetch == false
189
198
  log(:VERBOSE, "Fetching stable updates...")
190
199
  runGit("fetch #{@stable_repo}")
191
200
  end
201
+
202
+ # List all local branches matching the configured stable suffix name.
203
+ #
204
+ # @param br_suff [String] Branch suffix (e.g. 'master')
205
+ # @return [Array<String>] List of matching branch version strings (e.g., ['1.0'])
206
+ # @raise [RunError] If git branch execution fails
192
207
  def getBranchList(br_suff)
193
208
  return @branch_list if @branch_list != nil
194
209
 
195
210
  @branch_list=runGit("branch").split("\n").map(){|x|
196
- x=~ /#{@branch_format_raw}\/#{br_suff}$/ ?
211
+ x=~ Regexp.new("#{@branch_format_raw}/#{br_suff}$") ?
197
212
  $1 : nil
198
213
  }.compact().uniq()
199
214
 
200
215
  return @branch_list
201
216
  end
202
217
 
218
+ # List all remote stable branches from the stable remote.
219
+ #
220
+ # @return [Array<String>] List of remote stable branch version strings
221
+ # @raise [RunError] If git branch execution fails
203
222
  def getStableBranchList()
204
223
  return @stable_branches if @stable_branches != nil
205
224
 
206
225
  @stable_branches=runGit("branch -a").split("\n").map(){|x|
207
- x=~ /remotes\/#{@@STABLE_REPO}\/#{@stable_branch_format.gsub(/\\1/, '([0-9]+)')}$/ ?
226
+ x=~ Regexp.new("remotes/#{@@STABLE_REPO}/#{@stable_branch_format.gsub(/\\1/, '([0-9]+)')}$") ?
208
227
  $1 : nil
209
228
  }.compact().uniq()
210
229
 
211
230
  return @stable_branches
212
231
  end
213
232
 
233
+ # Get all local branch suffix values.
234
+ #
235
+ # @return [Array<String>] List of local branch suffixes
236
+ # @raise [RunError] If git branch execution fails
214
237
  def getSuffixList()
215
238
  return @suffix_list if @suffix_list != nil
216
239
 
217
240
  @suffix_list = runGit("branch").split("\n").map(){|x|
218
- x=~ @branch_format ?
219
- /^\*?\s*#{@branch_format_raw}\/([a-zA-Z0-9_-]+)\s*$/.match(x)[-1] :
241
+ x=~ @branch_format ?
242
+ Regexp.new("^\\*?\\s*#{@branch_format_raw}/([a-zA-Z0-9_-]+)\\s*$").match(x)[-1] :
220
243
  nil
221
244
  }.compact().uniq()
222
245
 
223
246
  return @suffix_list
224
247
  end
225
248
 
249
+ # Find local release tags that have not yet been pushed to the remote stable repository.
250
+ #
251
+ # @param opts [Hash] Options hash
252
+ # @return [Array<String>] List of unreleased version tags (e.g. ['v1.0.1'])
253
+ # @raise [RunError] If running git ls-remote or git tag fails
226
254
  def getUnreleasedTags(opts)
227
255
  remote_tags=runGit("ls-remote --tags #{@stable_repo} |
228
256
  grep -E 'refs/tags/v[0-9.]*$'").split("\n").map(){
@@ -233,6 +261,12 @@ module GitMaintain
233
261
  new_tags = local_tags - remote_tags
234
262
  return new_tags
235
263
  end
264
+
265
+ # Generate and transmit or save a release announcement email.
266
+ #
267
+ # @param opts [Hash] Options hash
268
+ # @param new_tags [Array<String>] List of newly released tags
269
+ # @raise [RunError] If running git show or other execution commands fails
236
270
  def genReleaseNotif(opts, new_tags)
237
271
  return if @NOTIFY_RELEASE == false
238
272
 
@@ -281,12 +315,25 @@ module GitMaintain
281
315
  end
282
316
  run("rm -f #{mail_path}")
283
317
  end
318
+
319
+ # Submit release tags by delegating to `createRelease`.
320
+ #
321
+ # @param opts [Hash] Options hash
322
+ # @param new_tags [Array<String>] List of release tags to submit
323
+ # @raise [GitMaintainError] If submitting release fails
284
324
  def submitReleases(opts, new_tags)
285
325
  new_tags.each(){|tag|
286
326
  createRelease(opts, tag)
287
327
  }
288
328
  end
289
329
 
330
+ # Create a release for the specified tag on GitHub.
331
+ # Pushes the tag to the remote stable repository and optionally calls GitHub release API.
332
+ #
333
+ # @param opts [Hash] Options hash
334
+ # @param tag [String] Release tag name (e.g. 'v1.0.1')
335
+ # @param github_rel [Boolean] True to create a GitHub release via API
336
+ # @raise [GitMaintainError] If git pushing or API release creation fails
290
337
  def createRelease(opts, tag, github_rel=true)
291
338
  log(:INFO, "Creating a release for #{tag}")
292
339
  runGit("push #{@stable_repo} refs/tags/#{tag}")
@@ -302,19 +349,33 @@ module GitMaintain
302
349
  end
303
350
  end
304
351
 
352
+ # Map a version number and suffix to a local branch name.
353
+ #
354
+ # @param version [String] Version string (e.g., '1.0')
355
+ # @param suff [String] Suffix string
356
+ # @return [String] Local branch name (e.g., 'stable/1.0/master')
305
357
  def versionToLocalBranch(version, suff)
306
358
  return @branch_format_raw.gsub(/\\\//, '/').
307
359
  gsub(/\(.*\)/, version) + "/#{suff}"
308
360
  end
309
361
 
362
+ # Map a version number to a stable branch name.
363
+ #
364
+ # @param version [String] Version string
365
+ # @return [String] Stable branch name
310
366
  def versionToStableBranch(version)
311
367
  return version.gsub(/^(.*)$/, @stable_branch_format)
312
368
  end
313
369
 
370
+ # Resolve the stable base commit/reference for a given branch.
371
+ #
372
+ # @param branch [String] Local branch name
373
+ # @return [String] Resolvable stable base reference
374
+ # @raise [GitMaintainError] If no stable base can be resolved for the branch
314
375
  def findStableBase(branch)
315
376
  base=nil
316
377
  if branch =~ @branch_format then
317
- base = branch.gsub(/^\*?\s*#{@branch_format_raw}\/.*$/, @stable_base_format)
378
+ base = branch.gsub(Regexp.new("^\\*?\\s*#{@branch_format_raw}/.*$"), @stable_base_format)
318
379
  end
319
380
 
320
381
  @stable_base_patterns.each(){|pattern, b|
@@ -323,16 +384,30 @@ module GitMaintain
323
384
  break
324
385
  end
325
386
  }
326
- raise("Could not a find a stable base for branch #{branch}") if base == nil
387
+ raise GitMaintainError.new("Could not a find a stable base for branch #{branch}") if base == nil
327
388
  return base
328
389
  end
329
390
 
391
+ # Print the local stable branches list to standard output.
392
+ #
393
+ # @param opts [Hash] Options hash
394
+ # @raise [RunError] If running git branch fails
330
395
  def list_branches(opts)
331
396
  puts getBranchList(opts[:br_suff])
332
397
  end
398
+
399
+ # Print all detected branch suffixes to standard output.
400
+ #
401
+ # @param opts [Hash] Options hash
402
+ # @raise [RunError] If running git branch fails
333
403
  def list_suffixes(opts)
334
404
  puts getSuffixList()
335
405
  end
406
+
407
+ # Find and submit any unreleased tags, generating announcements and creating GitHub releases.
408
+ #
409
+ # @param opts [Hash] Options hash
410
+ # @raise [GitMaintainError] If the release submission is aborted or fails
336
411
  def submit_release(opts)
337
412
  new_tags = getUnreleasedTags(opts)
338
413
  if new_tags.empty? then
@@ -341,9 +416,9 @@ module GitMaintain
341
416
  end
342
417
 
343
418
  log(:WARNING, "This will officially release these tags: #{new_tags.join(", ")}")
344
- rep = GitMaintain::confirm(opts, "release them", true)
419
+ rep = confirm(opts, "release them", true)
345
420
  if rep != 'y' then
346
- raise "Aborting.."
421
+ raise GitMaintainError.new("Aborting..")
347
422
  end
348
423
 
349
424
  if @NOTIFY_RELEASE != false
@@ -351,12 +426,17 @@ module GitMaintain
351
426
  end
352
427
 
353
428
  log(:WARNING, "Last chance to cancel before submitting")
354
- rep= GitMaintain::confirm(opts, "submit these releases", true)
429
+ rep= confirm(opts, "submit these releases", true)
355
430
  if rep != 'y' then
356
- raise "Aborting.."
431
+ raise GitMaintainError.new("Aborting..")
357
432
  end
358
433
  submitReleases(opts, new_tags)
359
434
  end
435
+
436
+ # Print a diagnostic summary of the repository configuration and detected local/upstream branches.
437
+ #
438
+ # @param opts [Hash] Options hash
439
+ # @raise [RunError] If running git commands fails
360
440
  def summary(opts)
361
441
  log(:INFO, "Configuration summary:")
362
442
  if self.class != GitMaintain::Repo then
@@ -409,12 +489,18 @@ module GitMaintain
409
489
  }
410
490
  end
411
491
  end
492
+
493
+ # Search remote stable branches for alternative commit SHAs with the same commit subject.
494
+ #
495
+ # @param commit [String] Original commit SHA
496
+ # @return [Array<String>] List of alternative commit SHAs with identical subject
497
+ # @raise [RunError] If running git commands fails
412
498
  def find_alts(commit)
413
499
  alts=[]
414
500
 
415
501
  begin
416
502
  subj=runGit("log -1 --pretty='%s' #{commit}")
417
- rescue RuntimeError
503
+ rescue RunError
418
504
  return []
419
505
  end
420
506
 
@@ -430,49 +516,58 @@ module GitMaintain
430
516
  return alts
431
517
  end
432
518
 
519
+ # Get or initialize the Octokit GitHub API client.
433
520
  #
434
- # Github API stuff
521
+ # @return [Octokit::Client] The initialized API client instance
522
+ def api
523
+ @api ||= Octokit::Client.new(:access_token => token, :auto_paginate => true)
524
+ end
525
+
526
+ # Get or fetch the cached GitHub OAuth API token.
435
527
  #
436
- def api
437
- @api ||= Octokit::Client.new(:access_token => token, :auto_paginate => true)
438
- end
439
-
440
- def token
441
- @token ||= begin
442
- # We cannot use the 'defaults' functionality of git_config here,
443
- # because get_new_token would be evaluated before git_config ran
444
- tok = getGitConfig("maintain.api-token")
528
+ # @return [String] The API token string
529
+ def token
530
+ @token ||= begin
531
+ # We cannot use the 'defaults' functionality of git_config here,
532
+ # because get_new_token would be evaluated before git_config ran
533
+ tok = getGitConfig("maintain.api-token")
445
534
  tok.to_s() == "" ? get_new_token : tok
446
- end
447
- end
448
- def get_new_token
449
- puts "Requesting a new OAuth token from Github..."
450
- print "Github username: "
451
- user = $stdin.gets.chomp
452
- print "Github password: "
453
- pass = $stdin.noecho(&:gets).chomp
454
- puts
455
-
456
- api = Octokit::Client.new(:login => user, :password => pass)
457
-
458
- begin
459
- res = api.create_authorization(:scopes => [:repo], :note => "git-maintain")
460
- rescue Octokit::Unauthorized
461
- puts "Username or password incorrect. Please try again."
462
- return get_new_token
535
+ end
536
+ end
537
+
538
+ # Interactively prompt the user for GitHub credentials to create and save a new OAuth token.
539
+ # Supports two-factor authentication (OneTimePasswordRequired).
540
+ #
541
+ # @return [String] The newly generated API token
542
+ # @raise [Octokit::Unauthorized] If username/password is incorrect
543
+ def get_new_token
544
+ puts "Requesting a new OAuth token from Github..."
545
+ print "Github username: "
546
+ user = $stdin.gets.chomp
547
+ print "Github password: "
548
+ pass = $stdin.noecho(&:gets).chomp
549
+ puts
550
+
551
+ api = Octokit::Client.new(:login => user, :password => pass)
552
+
553
+ begin
554
+ res = api.create_authorization(:scopes => [:repo], :note => "git-maintain")
555
+ rescue Octokit::Unauthorized
556
+ puts "Username or password incorrect. Please try again."
557
+ return get_new_token
463
558
  rescue Octokit::OneTimePasswordRequired
464
- print "Github OTP: "
465
- otp = $stdin.noecho(&:gets).chomp
466
- res = api.create_authorization(:scopes => [:repo], :note => "git-maintain",
559
+ print "Github OTP: "
560
+ otp = $stdin.noecho(&:gets).chomp
561
+ res = api.create_authorization(:scopes => [:repo], :note => "git-maintain",
467
562
  :headers => {"X-GitHub-OTP" => otp})
468
- end
563
+ end
469
564
 
470
- token = res[:token]
471
- runGit("config --global maintain.api-token '#{token}'")
565
+ token = res[:token]
566
+ runGit("config --global maintain.api-token '#{token}'")
472
567
 
473
568
  # Now reopen with the token so OTP does not bother us
474
569
  @api=nil
475
570
  token
476
- end
571
+ end
477
572
  end
478
573
  end