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,573 @@
1
+ require 'octokit'
2
+ require 'io/console'
3
+
4
+ # Main module for git-maintain repository maintenance tool.
5
+ module GitMaintain
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.
10
+ @@VALID_REPO = "github"
11
+ # Default name of the stable release repository.
12
+ @@STABLE_REPO = "stable"
13
+ # Default path to the release package submission helper command.
14
+ @@SUBMIT_BINARY="git-release"
15
+
16
+ # List of available actions for Repo class.
17
+ ACTION_LIST = [
18
+ :list_branches,
19
+ :summary,
20
+ # Internal commands for completion
21
+ :list_suffixes, :submit_release
22
+ ]
23
+ # Description map of actions for CLI help output.
24
+ ACTION_HELP = {
25
+ :submit_release => "Push the tags to 'stable' remote and create the release packages",
26
+ :summary => "Displays a summary of the configuration and the branches git-maintain sees"
27
+ }
28
+
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)
37
+ end
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
43
+ def self.check_opts(opts)
44
+ if opts[:action] == :submit_release then
45
+ if opts[:br_suff] != "master" then
46
+ raise GitMaintainError.new("Action #{opts[:action]} can only be done on 'master' suffixed branches")
47
+ end
48
+ end
49
+ end
50
+
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)
56
+ GitMaintain::checkDirectConstructor(self.class)
57
+
58
+ @path = path
59
+ @branch_list=nil
60
+ @stable_branches=nil
61
+ @suffix_list=nil
62
+ @config_cache={}
63
+
64
+ if path == nil
65
+ @path = Dir.pwd()
66
+ end
67
+ @name = File.basename(@path)
68
+
69
+ @valid_repo = getGitConfig("maintain.valid-repo")
70
+ @valid_repo = @@VALID_REPO if @valid_repo == ""
71
+ @stable_repo = getGitConfig("maintain.stable-repo")
72
+ @stable_repo = @@STABLE_REPO if @stable_repo == ""
73
+
74
+ @remote_valid=runGit("remote -v | grep -E '^#{@valid_repo}' | grep fetch |
75
+ awk '{ print $2}' | sed -e 's/.*://' -e 's/\\.git//'")
76
+ @remote_stable=runGit("remote -v | grep -E '^#{@stable_repo}' | grep fetch |
77
+ awk '{ print $2}' | sed -e 's/.*://' -e 's/\\.git//'")
78
+
79
+ @auto_fetch = getGitConfig("maintain.autofetch")
80
+ case @auto_fetch
81
+ when ""
82
+ @auto_fetch = nil
83
+ when "true", "yes", "on"
84
+ @auto_fetch = true
85
+ when "false", "no", "off"
86
+ @auto_fetch = false
87
+ else
88
+ raise GitMaintainError.new("Invalid value '#{@auto_fetch}' in git config for maintain.autofetch")
89
+ end
90
+
91
+ @branch_format_raw = getGitConfig("maintain.branch-format")
92
+ @branch_format = Regexp.new(@branch_format_raw)
93
+ @stable_branch_format = getGitConfig("maintain.stable-branch-format")
94
+ @stable_base_format = getGitConfig("maintain.stable-base-format")
95
+
96
+ @stable_base_patterns=
97
+ runGit("config --get-regexp stable-base | grep -E '^stable-base\.' | "+
98
+ "sed -e 's/stable-base\.//' -e 's/---/\\//g'").split("\n").inject({}){ |m, x|
99
+ y=x.split(" ");
100
+ m[y[0]] = y[1]
101
+ m
102
+ }
103
+
104
+ @mail_format = getGitConfig("maintain.mail-format")
105
+ if @mail_format == "" then
106
+ @mail_format = :imap_send
107
+ else
108
+ # Check that the format is valid
109
+ case @mail_format
110
+ when "imap_send", "send_email"
111
+ else
112
+ raise GitMaintainError.new("Invalid mail-format #{@mail_format}")
113
+ end
114
+
115
+ @mail_format = @mail_format.to_sym()
116
+ end
117
+ end
118
+ attr_reader :path, :name, :remote_valid, :remote_stable, :valid_repo, :stable_repo
119
+
120
+
121
+
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
127
+ def ref_exist?(ref)
128
+ begin
129
+ return runGit("rev-parse --verify --quiet '#{ref}'")
130
+ rescue RunError
131
+ raise(NoRefError.new(ref))
132
+ end
133
+ end
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
139
+ def runGitImap(cmd)
140
+ return `export GIT_ASKPASS=$(dirname $(dirname $(which git)))/lib/git-core/git-gui--askpass;
141
+ if [ ! -f $GIT_ASKPASS ]; then
142
+ export GIT_ASKPASS=$(dirname $(which git))/git-gui--askpass;
143
+ fi;
144
+ if [ ! -f $GIT_ASKPASS ]; then
145
+ export GIT_ASKPASS=/usr/lib/ssh/ssh-askpass;
146
+ fi; git --work-tree=#{@path} imap-send #{cmd}`
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
154
+ def getGitConfig(entry)
155
+ return @config_cache[entry] ||= runGit("config #{entry} 2> /dev/null", {}, false).chomp()
156
+ end
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
162
+ def runBash(env="")
163
+ begin
164
+ runSystem(env + " bash")
165
+ rescue RunError
166
+ log(:ERROR, "Shell exited with code #{$?}. Exiting")
167
+ raise GitMaintainError.new("Cancelled by user")
168
+ end
169
+ log(:INFO, "Continuing...")
170
+
171
+ end
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
178
+ def getCommitHeadline(sha)
179
+ return runGit("show --format=oneline --no-patch --no-decorate #{sha}")
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
187
+ def getCommitSubj(sha)
188
+ return runGit("log -1 --pretty=\"%s\" #{sha}")
189
+ end
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
195
+ def stableUpdate(fetch=nil)
196
+ fetch = @auto_fetch if fetch == nil
197
+ return if fetch == false
198
+ log(:VERBOSE, "Fetching stable updates...")
199
+ runGit("fetch #{@stable_repo}")
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
207
+ def getBranchList(br_suff)
208
+ return @branch_list if @branch_list != nil
209
+
210
+ @branch_list=runGit("branch").split("\n").map(){|x|
211
+ x=~ Regexp.new("#{@branch_format_raw}/#{br_suff}$") ?
212
+ $1 : nil
213
+ }.compact().uniq()
214
+
215
+ return @branch_list
216
+ end
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
222
+ def getStableBranchList()
223
+ return @stable_branches if @stable_branches != nil
224
+
225
+ @stable_branches=runGit("branch -a").split("\n").map(){|x|
226
+ x=~ Regexp.new("remotes/#{@@STABLE_REPO}/#{@stable_branch_format.gsub(/\\1/, '([0-9]+)')}$") ?
227
+ $1 : nil
228
+ }.compact().uniq()
229
+
230
+ return @stable_branches
231
+ end
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
237
+ def getSuffixList()
238
+ return @suffix_list if @suffix_list != nil
239
+
240
+ @suffix_list = runGit("branch").split("\n").map(){|x|
241
+ x=~ @branch_format ?
242
+ Regexp.new("^\\*?\\s*#{@branch_format_raw}/([a-zA-Z0-9_-]+)\\s*$").match(x)[-1] :
243
+ nil
244
+ }.compact().uniq()
245
+
246
+ return @suffix_list
247
+ end
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
254
+ def getUnreleasedTags(opts)
255
+ remote_tags=runGit("ls-remote --tags #{@stable_repo} |
256
+ grep -E 'refs/tags/v[0-9.]*$'").split("\n").map(){
257
+ |x| x.gsub(/.*refs\/tags\//, '')
258
+ }
259
+ local_tags =runGit("tag -l | grep -E '^v[0-9.]*$'").split("\n")
260
+
261
+ new_tags = local_tags - remote_tags
262
+ return new_tags
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
270
+ def genReleaseNotif(opts, new_tags)
271
+ return if @NOTIFY_RELEASE == false
272
+
273
+ mail_path=`mktemp`.chomp()
274
+ mail = File.open(mail_path, "w+")
275
+ mail.puts "From " + runGit("rev-parse HEAD") + " " + `date`.chomp()
276
+ mail.puts "From: " + getGitConfig("user.name") +
277
+ " <" + getGitConfig("user.email") +">"
278
+ mail.puts "To: " + getGitConfig("patch.target")
279
+ mail.puts "Date: " + `date -R`.chomp()
280
+
281
+ if new_tags.length > 4 then
282
+ mail.puts "Subject: [ANNOUNCE] " + File.basename(@path) + ": new stable releases"
283
+ mail.puts ""
284
+ mail.puts "These version were tagged/released:\n * " +
285
+ new_tags.join("\n * ")
286
+ mail.puts ""
287
+ else
288
+ mail.puts "Subject: [ANNOUNCE] " + File.basename(@path) + " " +
289
+ (new_tags.length > 1 ?
290
+ (new_tags[0 .. -2].join(", ") + " and " + new_tags[-1] + " have") :
291
+ (new_tags.join(" ") + " has")) +
292
+ " been tagged/released"
293
+ mail.puts ""
294
+ end
295
+ mail.puts "It's available at the normal places:"
296
+ mail.puts ""
297
+ mail.puts "git://github.com/#{@remote_stable}"
298
+ mail.puts "https://github.com/#{@remote_stable}/releases"
299
+ mail.puts ""
300
+ mail.puts "---"
301
+ mail.puts ""
302
+ mail.puts "Here's the information from the tags:"
303
+ new_tags.sort().each(){|tag|
304
+ mail.puts `git show #{tag} --no-decorate -q | awk '!p;/^-----END PGP SIGNATURE-----/{p=1}'`
305
+ mail.puts ""
306
+ }
307
+ mail.close()
308
+
309
+ case @mail_format
310
+ when :imap_send
311
+ puts runGitImap("< #{mail_path}")
312
+ when :send_email
313
+ run("cp #{mail_path} announce-release.eml")
314
+ log(:INFO, "Generated annoucement email in #{@path}/announce-release.eml")
315
+ end
316
+ run("rm -f #{mail_path}")
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
324
+ def submitReleases(opts, new_tags)
325
+ new_tags.each(){|tag|
326
+ createRelease(opts, tag)
327
+ }
328
+ end
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
337
+ def createRelease(opts, tag, github_rel=true)
338
+ log(:INFO, "Creating a release for #{tag}")
339
+ runGit("push #{@stable_repo} refs/tags/#{tag}")
340
+
341
+ if github_rel == true then
342
+ msg = runGit("tag -l -n1000 '#{tag}'") + "\n"
343
+
344
+ # Ye ghods is is a horrific format to parse
345
+ name, body = msg.split("\n", 2)
346
+ name = name.gsub(/^#{tag}/, '').strip
347
+ body = body.split("\n").map { |l| l.sub(/^ /, '') }.join("\n")
348
+ api.create_release(@remote_stable, tag, :name => name, :body => body)
349
+ end
350
+ end
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')
357
+ def versionToLocalBranch(version, suff)
358
+ return @branch_format_raw.gsub(/\\\//, '/').
359
+ gsub(/\(.*\)/, version) + "/#{suff}"
360
+ end
361
+
362
+ # Map a version number to a stable branch name.
363
+ #
364
+ # @param version [String] Version string
365
+ # @return [String] Stable branch name
366
+ def versionToStableBranch(version)
367
+ return version.gsub(/^(.*)$/, @stable_branch_format)
368
+ end
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
375
+ def findStableBase(branch)
376
+ base=nil
377
+ if branch =~ @branch_format then
378
+ base = branch.gsub(Regexp.new("^\\*?\\s*#{@branch_format_raw}/.*$"), @stable_base_format)
379
+ end
380
+
381
+ @stable_base_patterns.each(){|pattern, b|
382
+ if branch =~ /#{pattern}\// || branch =~ /#{pattern}$/
383
+ base = b
384
+ break
385
+ end
386
+ }
387
+ raise GitMaintainError.new("Could not a find a stable base for branch #{branch}") if base == nil
388
+ return base
389
+ end
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
395
+ def list_branches(opts)
396
+ puts getBranchList(opts[:br_suff])
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
403
+ def list_suffixes(opts)
404
+ puts getSuffixList()
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
411
+ def submit_release(opts)
412
+ new_tags = getUnreleasedTags(opts)
413
+ if new_tags.empty? then
414
+ log(:INFO, "All tags are already submitted.")
415
+ return
416
+ end
417
+
418
+ log(:WARNING, "This will officially release these tags: #{new_tags.join(", ")}")
419
+ rep = confirm(opts, "release them", true)
420
+ if rep != 'y' then
421
+ raise GitMaintainError.new("Aborting..")
422
+ end
423
+
424
+ if @NOTIFY_RELEASE != false
425
+ genReleaseNotif(opts, new_tags)
426
+ end
427
+
428
+ log(:WARNING, "Last chance to cancel before submitting")
429
+ rep= confirm(opts, "submit these releases", true)
430
+ if rep != 'y' then
431
+ raise GitMaintainError.new("Aborting..")
432
+ end
433
+ submitReleases(opts, new_tags)
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
440
+ def summary(opts)
441
+ log(:INFO, "Configuration summary:")
442
+ if self.class != GitMaintain::Repo then
443
+ log(:INFO, "Using custom repo class: #{self.class.to_s()}")
444
+ end
445
+ log(:INFO, "Stable remote: #{@stable_repo}")
446
+ log(:INFO, "Validation remote: #{@valid_repo}")
447
+ log(:INFO, "")
448
+ log(:INFO, "Branch config:")
449
+ log(:INFO, "Local branch format: /#{@branch_format_raw}/")
450
+ log(:INFO, "Remote stable branch format: #{@stable_branch_format}")
451
+ log(:INFO, "Remote stable base format: #{@stable_base_format}")
452
+
453
+ if @stable_base_patterns.length > 0 then
454
+ log(:INFO, "")
455
+ log(:INFO, "Stable base rules:")
456
+ @stable_base_patterns.each(){|name, base|
457
+ log(:INFO, "\t#{name} -> #{base}")
458
+ }
459
+ end
460
+ brList = getBranchList(opts[:br_suff])
461
+ brStList = getStableBranchList()
462
+
463
+ if brList.length > 0 then
464
+ log(:INFO, "")
465
+ log(:INFO, "Local branches:")
466
+ brList.each(){|br|
467
+ branch = Branch.load(self, br, nil, opts[:br_suff])
468
+ localBr = branch.local_branch
469
+ stableBr = @@STABLE_REPO + "/" + branch.remote_branch
470
+ stableBase = branch.stable_base
471
+ begin
472
+ ref_exist?(stableBr)
473
+ rescue NoRefError
474
+ stableBr = "<MISSING>"
475
+ end
476
+ log(:INFO, "\t#{localBr} -> #{stableBr} (#{stableBase})")
477
+ brStList.delete(br)
478
+ }
479
+ end
480
+
481
+ if brStList.length > 0 then
482
+ log(:INFO, "")
483
+ log(:INFO, "Upstream branches:")
484
+ brStList.each(){|br|
485
+ branch = Branch.load(self, br, nil, opts[:branch_suff])
486
+ stableBr = @@STABLE_REPO + "/" + branch.remote_branch
487
+ stableBase = branch.stable_base
488
+ log(:INFO, "\t<MISSING> -> #{stableBr} (#{stableBase})")
489
+ }
490
+ end
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
498
+ def find_alts(commit)
499
+ alts=[]
500
+
501
+ begin
502
+ subj=runGit("log -1 --pretty='%s' #{commit}")
503
+ rescue RunError
504
+ return []
505
+ end
506
+
507
+ branches = getStableBranchList().map(){|v| @@STABLE_REPO + "/" + versionToStableBranch(v)}
508
+
509
+ runGit("log -F --grep \"$#{subj}\" --format=\"%H\" #{branches.join(" ")}").
510
+ split("\n").each(){|c|
511
+ next if c == commit
512
+ cursubj=runGit("log -1 --pretty='%s' #{c}")
513
+ alts << c if subj == cursubj
514
+ }
515
+
516
+ return alts
517
+ end
518
+
519
+ # Get or initialize the Octokit GitHub API client.
520
+ #
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.
527
+ #
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")
534
+ tok.to_s() == "" ? get_new_token : tok
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
558
+ rescue Octokit::OneTimePasswordRequired
559
+ print "Github OTP: "
560
+ otp = $stdin.noecho(&:gets).chomp
561
+ res = api.create_authorization(:scopes => [:repo], :note => "git-maintain",
562
+ :headers => {"X-GitHub-OTP" => otp})
563
+ end
564
+
565
+ token = res[:token]
566
+ runGit("config --global maintain.api-token '#{token}'")
567
+
568
+ # Now reopen with the token so OTP does not bother us
569
+ @api=nil
570
+ token
571
+ end
572
+ end
573
+ end