thegarage-gitx 1.0.0.alpha
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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +60 -0
- data/Rakefile +5 -0
- data/bin/git-cleanup +4 -0
- data/bin/git-integrate +4 -0
- data/bin/git-nuke +4 -0
- data/bin/git-release +4 -0
- data/bin/git-reviewrequest +4 -0
- data/bin/git-share +4 -0
- data/bin/git-start +4 -0
- data/bin/git-track +4 -0
- data/bin/git-update +4 -0
- data/bin/git-wtf +364 -0
- data/lib/thegarage/gitx/cli.rb +139 -0
- data/lib/thegarage/gitx/git.rb +178 -0
- data/lib/thegarage/gitx/github.rb +60 -0
- data/lib/thegarage/gitx/string_extensions.rb +11 -0
- data/lib/thegarage/gitx/version.rb +5 -0
- data/lib/thegarage/gitx.rb +21 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/thegarage/gitx/cli_spec.rb +261 -0
- data/thegarage-gitx.gemspec +30 -0
- metadata +194 -0
@@ -0,0 +1,139 @@
|
|
1
|
+
require "thor"
|
2
|
+
require 'rest_client'
|
3
|
+
require 'thegarage/gitx'
|
4
|
+
|
5
|
+
module Thegarage
|
6
|
+
module Gitx
|
7
|
+
class CLI < Thor
|
8
|
+
include Thegarage::Gitx
|
9
|
+
include Thegarage::Gitx::Git
|
10
|
+
include Thegarage::Gitx::Github
|
11
|
+
|
12
|
+
PULL_REQUEST_DESCRIPTION = "\n\n" + <<-EOS.dedent
|
13
|
+
# Use GitHub flavored Markdown http://github.github.com/github-flavored-markdown/
|
14
|
+
# Links to screencasts or screenshots with a desciption of what this is showcasing. For architectual changes please include diagrams that will make it easier for the reviewer to understand the change. Format is .
|
15
|
+
# Link to ticket describing feature/bug (plantain, JIRA, bugzilla). Format is [title](url).
|
16
|
+
# Brief description of the change, and how it accomplishes the task they set out to do.
|
17
|
+
EOS
|
18
|
+
|
19
|
+
method_option :quiet, :type => :boolean, :aliases => '-q'
|
20
|
+
method_option :trace, :type => :boolean, :aliases => '-v'
|
21
|
+
def initialize(*args)
|
22
|
+
super(*args)
|
23
|
+
RestClient.proxy = ENV['HTTPS_PROXY'] if ENV.has_key?('HTTPS_PROXY')
|
24
|
+
RestClient.log = Logger.new(STDOUT) if options[:trace]
|
25
|
+
end
|
26
|
+
|
27
|
+
desc "reviewrequest", "Create a pull request on github"
|
28
|
+
method_option :description, :type => :string, :aliases => '-d', :desc => 'pull request description'
|
29
|
+
# @see http://developer.github.com/v3/pulls/
|
30
|
+
def reviewrequest
|
31
|
+
update
|
32
|
+
|
33
|
+
token = authorization_token
|
34
|
+
description = options[:description] || editor_input(PULL_REQUEST_DESCRIPTION)
|
35
|
+
branch = current_branch
|
36
|
+
repo = current_repo
|
37
|
+
url = create_pull_request token, branch, repo, description
|
38
|
+
say "Pull request created: #{url}"
|
39
|
+
end
|
40
|
+
|
41
|
+
# TODO: use --no-edit to skip merge messages
|
42
|
+
# TODO: use pull --rebase to skip merge commit
|
43
|
+
desc 'update', 'Update the current branch with latest changes from the remote feature branch and master'
|
44
|
+
def update
|
45
|
+
branch = current_branch
|
46
|
+
|
47
|
+
say 'updating '
|
48
|
+
say "#{branch} ", :green
|
49
|
+
say "to have most recent changes from "
|
50
|
+
say Thegarage::Gitx::BASE_BRANCH, :green
|
51
|
+
|
52
|
+
run_cmd "git pull origin #{branch}" rescue nil
|
53
|
+
run_cmd "git pull origin #{Thegarage::Gitx::BASE_BRANCH}"
|
54
|
+
run_cmd 'git push origin HEAD'
|
55
|
+
end
|
56
|
+
|
57
|
+
desc 'cleanup', 'Cleanup branches that have been merged into master from the repo'
|
58
|
+
def cleanup
|
59
|
+
run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
|
60
|
+
run_cmd "git pull"
|
61
|
+
run_cmd 'git remote prune origin'
|
62
|
+
|
63
|
+
say "Deleting branches that have been merged into "
|
64
|
+
say Thegarage::Gitx::BASE_BRANCH, :green
|
65
|
+
branches(:merged => true, :remote => true).each do |branch|
|
66
|
+
run_cmd "git push origin --delete #{branch}" unless aggregate_branch?(branch)
|
67
|
+
end
|
68
|
+
branches(:merged => true).each do |branch|
|
69
|
+
run_cmd "git branch -d #{branch}" unless aggregate_branch?(branch)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
desc 'track', 'set the current branch to track the remote branch with the same name'
|
74
|
+
def track
|
75
|
+
track_branch current_branch
|
76
|
+
end
|
77
|
+
|
78
|
+
desc 'start', 'start a new git branch with latest changes from master'
|
79
|
+
def start(branch_name = nil)
|
80
|
+
unless branch_name
|
81
|
+
example_branch = %w{ api-fix-invalid-auth desktop-cleanup-avatar-markup share-form-add-edit-link }.sort_by { rand }.first
|
82
|
+
repo = Grit::Repo.new(Dir.pwd)
|
83
|
+
remote_branches = repo.remotes.collect {|b| b.name.split('/').last }
|
84
|
+
until branch_name = ask("What would you like to name your branch? (ex: #{example_branch})") {|q|
|
85
|
+
q.validate = Proc.new { |branch|
|
86
|
+
branch =~ /^[A-Za-z0-9\-_]+$/ && !remote_branches.include?(branch)
|
87
|
+
}
|
88
|
+
}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
|
93
|
+
run_cmd 'git pull'
|
94
|
+
run_cmd "git checkout -b #{branch_name}"
|
95
|
+
end
|
96
|
+
|
97
|
+
desc 'share', 'Share the current branch in the remote repository'
|
98
|
+
def share
|
99
|
+
share_branch current_branch
|
100
|
+
end
|
101
|
+
|
102
|
+
desc 'integrate', 'integrate the current branch into one of the aggregate development branches'
|
103
|
+
def integrate(target_branch = 'staging')
|
104
|
+
branch = current_branch
|
105
|
+
|
106
|
+
update
|
107
|
+
integrate_branch(branch, target_branch)
|
108
|
+
run_cmd "git checkout #{branch}"
|
109
|
+
end
|
110
|
+
|
111
|
+
desc 'nuke', 'nuke the specified aggregate branch and reset it to a known good state'
|
112
|
+
method_option :destination, :type => :string, :aliases => '-d', :desc => 'destination branch to reset to'
|
113
|
+
def nuke(bad_branch)
|
114
|
+
default_good_branch = "last_known_good_#{bad_branch}"
|
115
|
+
good_branch = options[:destination] || ask("What branch do you want to reset #{bad_branch} to? (default: #{default_good_branch})")
|
116
|
+
good_branch = default_good_branch if good_branch.length == 0
|
117
|
+
good_branch = "last_known_good_#{good_branch}" unless good_branch.starts_with?('last_known_good_')
|
118
|
+
|
119
|
+
removed_branches = nuke_branch(bad_branch, good_branch)
|
120
|
+
nuke_branch("last_known_good_#{bad_branch}", good_branch)
|
121
|
+
end
|
122
|
+
|
123
|
+
desc 'release', 'release the current branch to production'
|
124
|
+
def release
|
125
|
+
branch = current_branch
|
126
|
+
assert_not_protected_branch!(branch, 'release')
|
127
|
+
update
|
128
|
+
|
129
|
+
return unless yes?("Release #{branch} to production? (y/n)", :green)
|
130
|
+
run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
|
131
|
+
run_cmd "git pull origin #{Thegarage::Gitx::BASE_BRANCH}"
|
132
|
+
run_cmd "git pull . #{branch}"
|
133
|
+
run_cmd "git push origin HEAD"
|
134
|
+
integrate_branch('master', 'staging')
|
135
|
+
cleanup
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
require 'grit'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
module Thegarage
|
5
|
+
module Gitx
|
6
|
+
module Git
|
7
|
+
AGGREGATE_BRANCHES = %w{ staging prototype }
|
8
|
+
RESERVED_BRANCHES = %w{ HEAD master next_release } + AGGREGATE_BRANCHES
|
9
|
+
|
10
|
+
private
|
11
|
+
def assert_not_protected_branch!(branch, action)
|
12
|
+
raise "Cannot #{action} reserved branch" if RESERVED_BRANCHES.include?(branch) || aggregate_branch?(branch)
|
13
|
+
end
|
14
|
+
|
15
|
+
# lookup the current branch of the PWD
|
16
|
+
def current_branch
|
17
|
+
repo = Grit::Repo.new(Dir.pwd)
|
18
|
+
Grit::Head.current(repo).name
|
19
|
+
end
|
20
|
+
|
21
|
+
# lookup the current repository of the PWD
|
22
|
+
# ex: git@github.com:socialcast/thegarage/gitx.git OR https://github.com/socialcast/thegarage/gitx.git
|
23
|
+
def current_repo
|
24
|
+
repo = `git config -z --get remote.origin.url`.strip
|
25
|
+
repo.gsub(/\.git$/,'').split(/[:\/]/).last(2).join('/')
|
26
|
+
end
|
27
|
+
|
28
|
+
# @returns [String] github username (ex: 'wireframe') of the current github.user
|
29
|
+
# @returns empty [String] when no github.user is set on the system
|
30
|
+
def current_user
|
31
|
+
`git config -z --get github.user`.strip
|
32
|
+
end
|
33
|
+
|
34
|
+
# @returns [String] auth token stored in git (current repo, user config or installed global settings)
|
35
|
+
def github_auth_token
|
36
|
+
`git config -z --get thegarage.gitx.github_auth_token`.strip
|
37
|
+
end
|
38
|
+
|
39
|
+
# store new auth token in the local project git config
|
40
|
+
def github_auth_token=(new_token)
|
41
|
+
`git config thegarage.gitx.github_auth_token "#{new_token}"`
|
42
|
+
end
|
43
|
+
|
44
|
+
# retrieve a list of branches
|
45
|
+
def branches(options = {})
|
46
|
+
branches = []
|
47
|
+
args = []
|
48
|
+
args << '-r' if options[:remote]
|
49
|
+
args << "--merged #{options[:merged].is_a?(String) ? options[:merged] : ''}" if options[:merged]
|
50
|
+
output = `git branch #{args.join(' ')}`.split("\n")
|
51
|
+
output.each do |branch|
|
52
|
+
branch = branch.gsub(/\*/, '').strip.split(' ').first
|
53
|
+
branch = branch.split('/').last if options[:remote]
|
54
|
+
branches << branch unless RESERVED_BRANCHES.include?(branch)
|
55
|
+
end
|
56
|
+
branches.uniq
|
57
|
+
end
|
58
|
+
|
59
|
+
# reset the specified branch to the same set of commits as the destination branch
|
60
|
+
# reverts commits on aggregate branches back to a known good state
|
61
|
+
# returns list of branches that were removed
|
62
|
+
def nuke_branch(branch, head_branch)
|
63
|
+
return [] if branch == head_branch
|
64
|
+
raise "Only aggregate branches are allowed to be reset: #{AGGREGATE_BRANCHES}" unless aggregate_branch?(branch)
|
65
|
+
say "Resetting "
|
66
|
+
say "#{branch} ", :green
|
67
|
+
say "branch to "
|
68
|
+
say head_branch, :green
|
69
|
+
|
70
|
+
run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
|
71
|
+
refresh_branch_from_remote head_branch
|
72
|
+
removed_branches = branches(:remote => true, :merged => "origin/#{branch}") - branches(:remote => true, :merged => "origin/#{head_branch}")
|
73
|
+
run_cmd "git branch -D #{branch}" rescue nil
|
74
|
+
run_cmd "git push origin --delete #{branch}" rescue nil
|
75
|
+
run_cmd "git checkout -b #{branch}"
|
76
|
+
share_branch branch
|
77
|
+
run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
|
78
|
+
|
79
|
+
removed_branches
|
80
|
+
end
|
81
|
+
|
82
|
+
# share the local branch in the remote repo
|
83
|
+
def share_branch(branch)
|
84
|
+
run_cmd "git push origin #{branch}"
|
85
|
+
track_branch branch
|
86
|
+
end
|
87
|
+
|
88
|
+
def track_branch(branch)
|
89
|
+
run_cmd "git branch --set-upstream #{branch} origin/#{branch}"
|
90
|
+
end
|
91
|
+
|
92
|
+
# integrate a branch into a destination aggregate branch
|
93
|
+
# blow away the local aggregate branch to ensure pulling into most recent "clean" branch
|
94
|
+
def integrate_branch(branch, destination_branch)
|
95
|
+
assert_not_protected_branch!(branch, 'integrate') unless aggregate_branch?(destination_branch)
|
96
|
+
raise "Only aggregate branches are allowed for integration: #{AGGREGATE_BRANCHES}" unless aggregate_branch?(destination_branch) || destination_branch == Thegarage::Gitx::BASE_BRANCH
|
97
|
+
say "Integrating "
|
98
|
+
say "#{branch} ", :green
|
99
|
+
say "into "
|
100
|
+
say destination_branch, :green
|
101
|
+
|
102
|
+
refresh_branch_from_remote destination_branch
|
103
|
+
run_cmd "git pull . #{branch}"
|
104
|
+
run_cmd "git push origin HEAD"
|
105
|
+
run_cmd "git checkout #{branch}"
|
106
|
+
end
|
107
|
+
|
108
|
+
# nuke local branch and pull fresh version from remote repo
|
109
|
+
def refresh_branch_from_remote(destination_branch)
|
110
|
+
run_cmd "git branch -D #{destination_branch}" rescue nil
|
111
|
+
run_cmd "git fetch origin"
|
112
|
+
run_cmd "git checkout #{destination_branch}"
|
113
|
+
end
|
114
|
+
|
115
|
+
def aggregate_branch?(branch)
|
116
|
+
AGGREGATE_BRANCHES.include?(branch) || branch.starts_with?('last_known_good')
|
117
|
+
end
|
118
|
+
|
119
|
+
# build a summary of changes
|
120
|
+
def changelog_summary(branch)
|
121
|
+
changes = `git diff --stat origin/#{Thegarage::Gitx::BASE_BRANCH}...#{branch}`.split("\n")
|
122
|
+
stats = changes.pop
|
123
|
+
if changes.length > 5
|
124
|
+
dirs = changes.map do |file_change|
|
125
|
+
filename = "#{file_change.split.first}"
|
126
|
+
dir = filename.gsub(/\/[^\/]+$/, '')
|
127
|
+
dir
|
128
|
+
end
|
129
|
+
dir_counts = Hash.new(0)
|
130
|
+
dirs.each {|dir| dir_counts[dir] += 1 }
|
131
|
+
changes = dir_counts.to_a.sort_by {|k,v| v}.reverse.first(5).map {|k,v| "#{k} (#{v} file#{'s' if v > 1})"}
|
132
|
+
end
|
133
|
+
(changes + [stats]).join("\n")
|
134
|
+
end
|
135
|
+
|
136
|
+
# launch configured editor to retreive message/string
|
137
|
+
def editor_input(initial_text = '')
|
138
|
+
require 'tempfile'
|
139
|
+
Tempfile.open('reviewrequest.md') do |f|
|
140
|
+
f << initial_text
|
141
|
+
f.flush
|
142
|
+
|
143
|
+
editor = ENV['EDITOR'] || 'vi'
|
144
|
+
flags = case editor
|
145
|
+
when 'mate', 'emacs'
|
146
|
+
'-w'
|
147
|
+
when 'mvim'
|
148
|
+
'-f'
|
149
|
+
else
|
150
|
+
''
|
151
|
+
end
|
152
|
+
pid = fork { exec "#{editor} #{flags} #{f.path}" }
|
153
|
+
Process.waitpid(pid)
|
154
|
+
description = File.read(f.path)
|
155
|
+
description.gsub(/^\#.*/, '').chomp.strip
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# load SC Git Extensions Config YAML
|
160
|
+
# @returns [Hash] of configuration options from YAML file (if it exists)
|
161
|
+
def config
|
162
|
+
@config ||= begin
|
163
|
+
if config_file.exist?
|
164
|
+
YAML.load_file(config_file)
|
165
|
+
else
|
166
|
+
{}
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# @returns a [Pathname] for the scgitx.yml Config File
|
172
|
+
# from either ENV['SCGITX_CONFIG_PATH'] or default $PWD/config/scgitx.yml
|
173
|
+
def config_file
|
174
|
+
Pathname((ENV['SCGITX_CONFIG_PATH'] || ([Dir.pwd, '/config/scgitx.yml']).join))
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'thegarage/gitx/git'
|
2
|
+
require 'rest_client'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Thegarage
|
6
|
+
module Gitx
|
7
|
+
module Github
|
8
|
+
include Thegarage::Gitx::Git
|
9
|
+
|
10
|
+
private
|
11
|
+
# request github authorization token
|
12
|
+
# User-Agent is required
|
13
|
+
# store the token in ~/.socialcast/credentials.yml for future reuse
|
14
|
+
# @see http://developer.github.com/v3/oauth/#scopes
|
15
|
+
# @see http://developer.github.com/v3/#user-agent-required
|
16
|
+
def authorization_token
|
17
|
+
auth_token = github_auth_token
|
18
|
+
return auth_token if auth_token
|
19
|
+
|
20
|
+
username = current_user
|
21
|
+
raise "Github user not configured. Run: `git config --global github.user 'me@email.com'`" if username.empty?
|
22
|
+
password = ask("Github password for #{username}: ") { |q| q.echo = false }
|
23
|
+
|
24
|
+
payload = {:scopes => ['repo'], :note => 'Socialcast Git eXtension', :note_url => 'https://github.com/socialcast/thegarage/gitx'}.to_json
|
25
|
+
response = RestClient::Request.new(:url => "https://api.github.com/authorizations", :method => "POST", :user => username, :password => password, :payload => payload, :headers => {:accept => :json, :content_type => :json, :user_agent => 'thegarage/gitx'}).execute
|
26
|
+
data = JSON.parse response.body
|
27
|
+
token = data['token']
|
28
|
+
github_auth_token = token
|
29
|
+
token
|
30
|
+
rescue RestClient::Exception => e
|
31
|
+
process_error e
|
32
|
+
throw e
|
33
|
+
end
|
34
|
+
|
35
|
+
# returns the url of the created pull request
|
36
|
+
# @see http://developer.github.com/v3/pulls/
|
37
|
+
def create_pull_request(token, branch, repo, body)
|
38
|
+
payload = {:title => branch, :base => Thegarage::Gitx::BASE_BRANCH, :head => branch, :body => body}.to_json
|
39
|
+
say "Creating pull request for "
|
40
|
+
say "#{branch} ", :green
|
41
|
+
say "against "
|
42
|
+
say "#{Thegarage::Gitx::BASE_BRANCH} ", :green
|
43
|
+
say "in "
|
44
|
+
say repo, :green
|
45
|
+
response = RestClient::Request.new(:url => "https://api.github.com/repos/#{repo}/pulls", :method => "POST", :payload => payload, :headers => {:accept => :json, :content_type => :json, 'Authorization' => "token #{token}"}).execute
|
46
|
+
data = JSON.parse response.body
|
47
|
+
url = data['html_url']
|
48
|
+
url
|
49
|
+
rescue RestClient::Exception => e
|
50
|
+
process_error e
|
51
|
+
throw e
|
52
|
+
end
|
53
|
+
|
54
|
+
def process_error(e)
|
55
|
+
data = JSON.parse e.http_body
|
56
|
+
say "Failed to create pull request: #{data['message']}", :red
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "thegarage/gitx/version"
|
2
|
+
require 'thegarage/gitx/version'
|
3
|
+
require 'thegarage/gitx/string_extensions'
|
4
|
+
require 'thegarage/gitx/git'
|
5
|
+
require 'thegarage/gitx/github'
|
6
|
+
|
7
|
+
|
8
|
+
module Thegarage
|
9
|
+
module Gitx
|
10
|
+
BASE_BRANCH = 'master'
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
# execute a shell command and raise an error if non-zero exit code is returned
|
15
|
+
def run_cmd(cmd)
|
16
|
+
say "\n$ "
|
17
|
+
say cmd.gsub("'", ''), :red
|
18
|
+
raise "#{cmd} failed" unless system cmd
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'rspec/mocks'
|
4
|
+
require 'webmock/rspec'
|
5
|
+
require 'pry'
|
6
|
+
RSpec::Mocks::setup(Object.new)
|
7
|
+
|
8
|
+
require 'thegarage/gitx/cli'
|
9
|
+
|
10
|
+
RSpec.configure do |config|
|
11
|
+
config.mock_with :rspec
|
12
|
+
|
13
|
+
def capture_with_status(stream)
|
14
|
+
exit_status = 0
|
15
|
+
begin
|
16
|
+
stream = stream.to_s
|
17
|
+
eval "$#{stream} = StringIO.new"
|
18
|
+
begin
|
19
|
+
yield
|
20
|
+
rescue SystemExit => system_exit # catch any exit calls
|
21
|
+
exit_status = system_exit.status
|
22
|
+
end
|
23
|
+
result = eval("$#{stream}").string
|
24
|
+
ensure
|
25
|
+
eval("$#{stream} = #{stream.upcase}")
|
26
|
+
end
|
27
|
+
return result, exit_status
|
28
|
+
end
|
29
|
+
|
30
|
+
def remove_directories(*names)
|
31
|
+
project_dir = Pathname.new(Dir.pwd)
|
32
|
+
names.each do |name|
|
33
|
+
FileUtils.rm_rf(project_dir.join(name)) if FileTest.exists?(project_dir.join(name))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|