git-review 1.1.6 → 1.1.7

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,331 @@
1
+ module GitReview
2
+
3
+ module Commands
4
+
5
+ include ::GitReview::Internals
6
+ extend self
7
+
8
+ # List all pending requests.
9
+ def list(reverse=false)
10
+ requests = github.current_requests_full.reject { |request|
11
+ # find only pending (= unmerged) requests and output summary
12
+ # explicitly look for local changes Github does not yet know about
13
+ local.merged?(request.head.sha)
14
+ }
15
+ requests.reverse! if reverse
16
+ source = local.source
17
+ if requests.empty?
18
+ puts "No pending requests for '#{source}'."
19
+ else
20
+ puts "Pending requests for '#{source}':"
21
+ puts "ID Updated Comments Title"
22
+ requests.each { |request| print_request(request) }
23
+ end
24
+ end
25
+
26
+ # Show details for a single request.
27
+ def show(number, full=false)
28
+ request = get_request_by_number(number)
29
+ # determine whether to show full diff or just stats
30
+ option = full ? '' : '--stat '
31
+ diff = "diff --color=always #{option}HEAD...#{request.head.sha}"
32
+ print_request_details(request)
33
+ puts git_call(diff)
34
+ print_request_discussions(request)
35
+ end
36
+
37
+ # Open a browser window and review a specified request.
38
+ def browse(number)
39
+ request = get_request_by_number(number)
40
+ Launchy.open(request.html_url)
41
+ end
42
+
43
+ # Checkout a specified request's changes to your local repository.
44
+ def checkout(number, branch=false)
45
+ request = get_request_by_number(number)
46
+ puts 'Checking out changes to your local repository.'
47
+ puts 'To get back to your original state, just run:'
48
+ puts
49
+ puts ' git checkout master'
50
+ puts
51
+ if branch
52
+ git_call("checkout #{request.head.ref}")
53
+ else
54
+ git_call("checkout pr/#{request.number}")
55
+ end
56
+ end
57
+
58
+ # Add an approving comment to the request.
59
+ def approve(number)
60
+ request = get_request_by_number(number)
61
+ repo = github.source_repo
62
+ # TODO: Make this configurable.
63
+ comment = 'Reviewed and approved.'
64
+ response = github.add_comment(repo, request.number, comment)
65
+ if response[:body] == comment
66
+ puts 'Successfully approved request.'
67
+ else
68
+ puts response[:message]
69
+ end
70
+ end
71
+
72
+ # Accept a specified request by merging it into master.
73
+ def merge(number)
74
+ request = get_request_by_number(number)
75
+ if request.head.repo
76
+ message = "Accept request ##{request.number} " +
77
+ "and merge changes into \"#{local.target}\""
78
+ command = "merge -m '#{message}' #{request.head.sha}"
79
+ puts
80
+ puts "Request title:"
81
+ puts " #{request.title}"
82
+ puts
83
+ puts "Merge command:"
84
+ puts " git #{command}"
85
+ puts
86
+ puts git_call(command)
87
+ else
88
+ print_repo_deleted(request)
89
+ end
90
+ end
91
+
92
+ # Close a specified request.
93
+ def close(number)
94
+ request = get_request_by_number(number)
95
+ repo = github.source_repo
96
+ github.close_issue(repo, request.number)
97
+ unless github.request_exists?('open', request.number)
98
+ puts 'Successfully closed request.'
99
+ end
100
+ end
101
+
102
+ # Prepare local repository to create a new request.
103
+ # People should work on local branches, but especially for single commit
104
+ # changes, more often than not, they don't. Therefore we create a branch
105
+ # for them, to be able to use code review the way it is intended.
106
+ # @return [Array(String, String)] the original branch and the local branch
107
+ def prepare(new=false, name=nil)
108
+ # remember original branch the user was currently working on
109
+ original_branch = local.source_branch
110
+ if new || !local.on_feature_branch?
111
+ local_branch = move_uncommitted_changes(local.target_branch, name)
112
+ else
113
+ local_branch = original_branch
114
+ end
115
+ [original_branch, local_branch]
116
+ end
117
+
118
+ # Create a new request.
119
+ # TODO: Support creating requests to other repositories and branches (like
120
+ # the original repo, this has been forked from).
121
+ def create(upstream=false)
122
+ # prepare original_branch and local_branch
123
+ original_branch, local_branch = prepare
124
+ # don't create request with uncommitted changes in current branch
125
+ unless git_call('diff HEAD').empty?
126
+ puts 'You have uncommitted changes.'
127
+ puts 'Please stash or commit before creating the request.'
128
+ return
129
+ end
130
+ if git_call("cherry #{local.target_branch}").empty?
131
+ puts 'Nothing to push to remote yet. Commit something first.'
132
+ else
133
+ if github.request_exists_for_branch?(upstream)
134
+ puts 'A pull request already exists for this branch.'
135
+ puts 'Please update the request directly using `git push`.'
136
+ return
137
+ end
138
+ # push latest commits to the remote branch (create if necessary)
139
+ git_call("push --set-upstream origin #{local_branch}", debug_mode, true)
140
+ create_pull_request(upstream)
141
+ # return to the user's original branch
142
+ # FIXME: keep track of original branch etc
143
+ git_call("checkout #{original_branch}")
144
+ end
145
+ end
146
+
147
+ # delete obsolete branches (left over from already closed requests)
148
+ def clean(number=nil, force=false, all=false)
149
+ # pruning is needed to remove deleted branches from your local track
150
+ git_call('remote prune origin')
151
+ # determine strategy to clean.
152
+ if all
153
+ local.clean_all
154
+ else
155
+ local.clean_single(number, force)
156
+ end
157
+ end
158
+
159
+ # Start a console session (used for debugging)
160
+ def console
161
+ puts 'Entering debug console.'
162
+ if RUBY_VERSION == '2.0.0'
163
+ require 'byebug'
164
+ byebug
165
+ else
166
+ require 'ruby-debug'
167
+ Debugger.start
168
+ debugger
169
+ end
170
+ puts 'Leaving debug console.'
171
+ end
172
+
173
+ private
174
+
175
+ def print_request(request)
176
+ date_string = format_time(request.updated_at)
177
+ comments_count = request.comments.to_i + request.review_comments.to_i
178
+ line = format_text(request.number, 8)
179
+ line << format_text(date_string, 11)
180
+ line << format_text(comments_count, 10)
181
+ line << format_text(request.title, 91)
182
+ puts line
183
+ end
184
+
185
+ def print_request_details(request)
186
+ comments_count = request.comments.to_i + request.review_comments.to_i
187
+ puts 'ID : ' + request.number.to_s
188
+ puts 'Label : ' + request.head.label
189
+ puts 'Updated : ' + format_time(request.updated_at)
190
+ puts 'Comments : ' + comments_count.to_s
191
+ puts
192
+ puts request.title
193
+ puts
194
+ unless request.body.empty?
195
+ puts request.body
196
+ puts
197
+ end
198
+ end
199
+
200
+ def print_request_discussions(request)
201
+ puts 'Progress :'
202
+ puts
203
+ puts github.discussion(request.number)
204
+ end
205
+
206
+ # someone deleted the source repo
207
+ def print_repo_deleted(request)
208
+ user = request.head.user.login
209
+ url = request.patch_url
210
+ puts "Sorry, #{user} deleted the source repository."
211
+ puts "git-review doesn't support this."
212
+ puts "Tell the contributor not to do this."
213
+ puts
214
+ puts "You can still manually patch your repo by running:"
215
+ puts
216
+ puts " curl #{url} | git am"
217
+ puts
218
+ end
219
+
220
+ # ask for branch name if not provided
221
+ # @return [String] sanitized branch name
222
+ def get_branch_name
223
+ puts 'Please provide a name for the branch:'
224
+ branch_name = gets.chomp
225
+ branch_name.gsub(/\W+/, '_').downcase
226
+ end
227
+
228
+ # move uncommitted changes from target branch to local branch
229
+ # @return [String] the new local branch uncommitted changes are moved to
230
+ def move_uncommitted_changes(target_branch, new_branch)
231
+ new_branch ||= get_branch_name
232
+ local_branch = "review_#{Time.now.strftime("%y%m%d")}_#{new_branch}"
233
+ git_call("checkout -b #{local_branch}")
234
+ # make sure we are on the feature branch
235
+ if local.source_branch == local_branch
236
+ # stash any uncommitted changes
237
+ save_uncommitted_changes = local.uncommitted_changes?
238
+ git_call('stash') if save_uncommitted_changes
239
+ # go back to target and get rid of pending commits
240
+ git_call("checkout #{target_branch}")
241
+ git_call("reset --hard origin/#{target_branch}")
242
+ git_call("checkout #{local_branch}")
243
+ git_call('stash pop') if save_uncommitted_changes
244
+ local_branch
245
+ end
246
+ end
247
+
248
+ def create_pull_request(to_upstream=false)
249
+ target_repo = local.target_repo(to_upstream)
250
+ head = local.head
251
+ base = local.target_branch
252
+ title, body = create_title_and_body(base)
253
+
254
+ # gather information before creating pull request
255
+ lastest_number = github.latest_request_number(target_repo)
256
+
257
+ # create the actual pull request
258
+ github.create_pull_request(target_repo, base, head, title, body)
259
+ # switch back to target_branch and check for success
260
+ git_call("checkout #{base}")
261
+
262
+ # make sure the new pull request is indeed created
263
+ new_number = github.request_number_by_title(title, target_repo)
264
+ if new_number && new_number > lastest_number
265
+ puts "Successfully created new request ##{new_number}"
266
+ puts "https://github.com/#{target_repo}/pull/#{new_number}"
267
+ else
268
+ puts "Pull request was not created for #{target_repo}."
269
+ end
270
+ end
271
+
272
+
273
+ # @return [Array(String, String)] the title and the body of pull request
274
+ def create_title_and_body(target_branch)
275
+ source = local.source
276
+ login = github.github.login
277
+ commits = git_call("log --format='%H' HEAD...#{target_branch}").
278
+ lines.count
279
+ puts "commits: #{commits}"
280
+ if commits == 1
281
+ # we can create a really specific title and body
282
+ title = git_call("log --format='%s' HEAD...#{target_branch}").chomp
283
+ body = git_call("log --format='%b' HEAD...#{target_branch}").chomp
284
+ else
285
+ title = "[Review] Request from '#{login}' @ '#{source}'"
286
+ body = "Please review the following changes:\n"
287
+ body += git_call("log --oneline HEAD...#{target_branch}").
288
+ lines.map{|l| " * #{l.chomp}"}.join("\n")
289
+ end
290
+ edit_title_and_body(title, body)
291
+ end
292
+
293
+ # TODO: refactor
294
+ def edit_title_and_body(title, body)
295
+ tmpfile = Tempfile.new('git-review')
296
+ tmpfile.write(title + "\n\n" + body)
297
+ tmpfile.flush
298
+ editor = ENV['TERM_EDITOR'] || ENV['EDITOR']
299
+ unless editor
300
+ warn 'Please set $EDITOR or $TERM_EDITOR in your .bash_profile.'
301
+ end
302
+
303
+ system("#{editor || 'open'} #{tmpfile.path}")
304
+
305
+ tmpfile.rewind
306
+ lines = tmpfile.read.lines.to_a
307
+ puts lines.inspect
308
+ title = lines.shift.chomp
309
+ lines.shift if lines[0].chomp.empty?
310
+ body = lines.join
311
+ tmpfile.unlink
312
+ [title, body]
313
+ end
314
+
315
+ def github
316
+ @github ||= ::GitReview::Github.instance
317
+ end
318
+
319
+ def local
320
+ @local ||= ::GitReview::Local.instance
321
+ end
322
+
323
+ def get_request_by_number(request_number)
324
+ request = github.request_exists?(request_number)
325
+ request || (raise ::GitReview::InvalidRequestIDError)
326
+ end
327
+
328
+ end
329
+
330
+ end
331
+
@@ -0,0 +1,34 @@
1
+ module GitReview
2
+
3
+ class AuthenticationError < StandardError
4
+ def message
5
+ 'You provided the wrong username/password, please try again.'
6
+ end
7
+ end
8
+
9
+ class InvalidGitRepositoryError < StandardError
10
+ def message
11
+ "It is not a valid git repository or doesn't have a valid remote url."
12
+ end
13
+ end
14
+
15
+ # A custom error to raise, if we know we can't go on.
16
+ class UnprocessableState < StandardError
17
+ def message
18
+ 'Execution of git-review stopped.'
19
+ end
20
+ end
21
+
22
+ class InvalidArgumentError < StandardError
23
+ def message
24
+ 'Please specify valid arguments. See --help for more information.'
25
+ end
26
+ end
27
+
28
+ class InvalidRequestIDError < StandardError
29
+ def message
30
+ 'Please specify a valid request ID.'
31
+ end
32
+ end
33
+
34
+ end
@@ -0,0 +1,289 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ # Used to handle json data
4
+ require 'yajl'
5
+ # Required to hide password
6
+ require 'io/console'
7
+ # Required by yajl for decoding
8
+ require 'stringio'
9
+ # Used to retrieve hostname
10
+ require 'socket'
11
+
12
+
13
+ module GitReview
14
+
15
+ class Github
16
+
17
+ include ::GitReview::Internals
18
+
19
+ attr_reader :github
20
+ attr_accessor :source_repo
21
+
22
+ # acts like a singleton class but it's actually not
23
+ # use ::GitReview::Github.instance everywhere except in tests
24
+ def self.instance
25
+ @instance ||= new
26
+ end
27
+
28
+ def initialize
29
+ configure_github_access
30
+ end
31
+
32
+ # setup connection with Github via OAuth
33
+ # @return [String] the username logged in
34
+ def configure_github_access
35
+ settings = ::GitReview::Settings.instance
36
+ if settings.oauth_token && settings.username
37
+ @github = Octokit::Client.new(
38
+ :login => settings.username,
39
+ :access_token => settings.oauth_token,
40
+ :auto_traversal => true
41
+ )
42
+ @github.login
43
+ else
44
+ configure_oauth
45
+ configure_github_access
46
+ end
47
+ end
48
+
49
+ # @return [Boolean, Hash] the specified request if exists, otherwise false.
50
+ # Instead of true, the request itself is returned, so another round-trip
51
+ # of pull_request can be avoided.
52
+ def request_exists?(number, state='open')
53
+ return false if number.nil?
54
+ request = @github.pull_request(source_repo, number)
55
+ request.state == state ? request : false
56
+ rescue Octokit::NotFound
57
+ false
58
+ end
59
+
60
+ def request_exists_for_branch?(upstream=false, branch=local.source_branch)
61
+ target_repo = local.target_repo(upstream)
62
+ @github.pull_requests(target_repo).any? { |r|
63
+ r.head.ref == branch
64
+ }
65
+ end
66
+
67
+ # an alias to pull_requests
68
+ def current_requests(repo=source_repo)
69
+ @github.pull_requests(repo)
70
+ end
71
+
72
+ # a more detailed collection of requests
73
+ def current_requests_full(repo=source_repo)
74
+ @github.pull_requests(repo).collect { |request|
75
+ @github.pull_request(repo, request.number)
76
+ }
77
+ end
78
+
79
+ def update
80
+ git_call('fetch origin')
81
+ end
82
+
83
+ # @return [Array(String, String)] user and repo name from local git config
84
+ def repo_info_from_config
85
+ git_config = local.config
86
+ url = git_config['remote.origin.url']
87
+ raise ::GitReview::InvalidGitRepositoryError if url.nil?
88
+
89
+ user, project = github_url_matching(url)
90
+ # if there are no results yet, look for 'insteadof' substitutions
91
+ # in URL and try again
92
+ unless user && project
93
+ insteadof_url, true_url = github_insteadof_matching(git_config, url)
94
+ if insteadof_url and true_url
95
+ url = url.sub(insteadof_url, true_url)
96
+ user, project = github_url_matching(url)
97
+ end
98
+ end
99
+ [user, project]
100
+ end
101
+
102
+ # @return [String] the source repo
103
+ def source_repo
104
+ # cache source_repo
105
+ if @source_repo
106
+ @source_repo
107
+ else
108
+ user, repo = repo_info_from_config
109
+ @source_repo = "#{user}/#{repo}" if user && repo
110
+ end
111
+ end
112
+
113
+ def commit_discussion(number)
114
+ pull_commits = @github.pull_commits(source_repo, number)
115
+ repo = @github.pull_request(source_repo, number).head.repo.full_name
116
+ discussion = ["Commits on pull request:\n\n"]
117
+ discussion += pull_commits.collect { |commit|
118
+ # commit message
119
+ name = commit.committer.login
120
+ output = "\e[35m#{name}\e[m "
121
+ output << "committed \e[36m#{commit.sha[0..6]}\e[m "
122
+ output << "on #{format_time(commit.commit.committer.date)}"
123
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
124
+ output << "#{commit.commit.message}"
125
+ output << "\n\n"
126
+ result = [output]
127
+
128
+ # comments on commit
129
+ comments = @github.commit_comments(repo, commit.sha)
130
+ result + comments.collect { |comment|
131
+ name = comment.user.login
132
+ output = "\e[35m#{name}\e[m "
133
+ output << "added a comment to \e[36m#{commit.sha[0..6]}\e[m"
134
+ output << " on #{format_time(comment.created_at)}"
135
+ unless comment.created_at == comment.updated_at
136
+ output << " (updated on #{format_time(comment.updated_at)})"
137
+ end
138
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
139
+ output << comment.body
140
+ output << "\n\n"
141
+ }
142
+ }
143
+ discussion.compact.flatten unless discussion.empty?
144
+ end
145
+
146
+ def issue_discussion(number)
147
+ comments = @github.issue_comments(source_repo, number)
148
+ discussion = ["\nComments on pull request:\n\n"]
149
+ discussion += comments.collect { |comment|
150
+ name = comment.user.login
151
+ output = "\e[35m#{name}\e[m "
152
+ output << "added a comment to \e[36m#{comment.id}\e[m"
153
+ output << " on #{format_time(comment.created_at)}"
154
+ unless comment.created_at == comment.updated_at
155
+ output << " (updated on #{format_time(comment.updated_at)})"
156
+ end
157
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
158
+ output << comment.body
159
+ output << "\n\n"
160
+ }
161
+ discussion.compact.flatten unless discussion.empty?
162
+ end
163
+
164
+ # show discussion for a request
165
+ def discussion(number)
166
+ commit_discussion(number) +
167
+ issue_discussion(number)
168
+ end
169
+
170
+ # show latest pull request number
171
+ def latest_request_number(repo=source_repo)
172
+ current_requests(repo).collect(&:number).sort.last.to_i
173
+ end
174
+
175
+ # get the number of the request that matches the title
176
+ def request_number_by_title(title, repo=source_repo)
177
+ request = current_requests(repo).find { |r| r.title == title }
178
+ request.number if request
179
+ end
180
+
181
+ # delegate methods that interact with Github to Octokit client
182
+ def method_missing(method, *args)
183
+ if @github.respond_to?(method)
184
+ @github.send(method, *args)
185
+ else
186
+ super
187
+ end
188
+ end
189
+
190
+ def respond_to?(method)
191
+ @github.respond_to?(method) || super
192
+ end
193
+
194
+ private
195
+
196
+ def configure_oauth
197
+ begin
198
+ prepare_username_and_password
199
+ prepare_description
200
+ authorize
201
+ rescue ::GitReview::AuthenticationError => e
202
+ warn e.message
203
+ rescue ::GitReview::UnprocessableState => e
204
+ warn e.message
205
+ exit 1
206
+ end
207
+ end
208
+
209
+ def prepare_username_and_password
210
+ puts "Requesting a OAuth token for git-review."
211
+ puts "This procedure will grant access to your public and private "\
212
+ "repositories."
213
+ puts "You can revoke this authorization by visiting the following page: "\
214
+ "https://github.com/settings/applications"
215
+ print "Please enter your GitHub's username: "
216
+ @username = STDIN.gets.chomp
217
+ print "Please enter your GitHub's password (it won't be stored anywhere): "
218
+ @password = STDIN.noecho(&:gets).chomp
219
+ print "\n"
220
+ end
221
+
222
+ def prepare_description(chosen_description=nil)
223
+ if chosen_description
224
+ @description = chosen_description
225
+ else
226
+ @description = "git-review - #{Socket.gethostname}"
227
+ puts "Please enter a description to associate to this token, it will "\
228
+ "make easier to find it inside of GitHub's application page."
229
+ puts "Press enter to accept the proposed description"
230
+ print "Description [#{@description}]:"
231
+ user_description = STDIN.gets.chomp
232
+ @description = user_description.empty? ? @description : user_description
233
+ end
234
+ end
235
+
236
+ def authorize
237
+ uri = URI('https://api.github.com/authorizations')
238
+ http = Net::HTTP.new(uri.host, uri.port)
239
+ http.use_ssl = true
240
+ req = Net::HTTP::Post.new(uri.request_uri)
241
+ req.basic_auth(@username, @password)
242
+ req.body = Yajl::Encoder.encode(
243
+ {
244
+ :scopes => %w(repo),
245
+ :note => @description
246
+ }
247
+ )
248
+ response = http.request(req)
249
+ if response.code == '201'
250
+ parser_response = Yajl::Parser.parse(response.body)
251
+ save_oauth_token(parser_response['token'])
252
+ elsif response.code == '401'
253
+ raise ::GitReview::AuthenticationError
254
+ else
255
+ raise ::GitReview::UnprocessableState, response.body
256
+ end
257
+ end
258
+
259
+ def save_oauth_token(token)
260
+ settings = ::GitReview::Settings.instance
261
+ settings.oauth_token = token
262
+ settings.username = @username
263
+ settings.save!
264
+ puts "OAuth token successfully created.\n"
265
+ end
266
+
267
+ # extract user and project name from GitHub URL.
268
+ def github_url_matching(url)
269
+ matches = /github\.com.(.*?)\/(.*)/.match(url)
270
+ matches ? [matches[1], matches[2].sub(/\.git\z/, '')] : [nil, nil]
271
+ end
272
+
273
+ # look for 'insteadof' substitutions in URL.
274
+ def github_insteadof_matching(config, url)
275
+ first_match = config.keys.collect { |key|
276
+ [config[key], /url\.(.*github\.com.*)\.insteadof/.match(key)]
277
+ }.find { |insteadof_url, true_url|
278
+ url.index(insteadof_url) and true_url != nil
279
+ }
280
+ first_match ? [first_match[0], first_match[1][1]] : [nil, nil]
281
+ end
282
+
283
+ def local
284
+ @local ||= ::GitReview::Local.instance
285
+ end
286
+
287
+ end
288
+
289
+ end
@@ -0,0 +1,47 @@
1
+ module GitReview
2
+
3
+ module Internals
4
+
5
+ private
6
+
7
+ # System call to 'git'.
8
+ def git_call(command, verbose = debug_mode, enforce_success = false)
9
+ if verbose
10
+ puts
11
+ puts " git #{command}"
12
+ puts
13
+ end
14
+ output = `git #{command}`
15
+ puts output if verbose and not output.empty?
16
+ # If we need sth. to succeed, but it doesn't, then stop right there.
17
+ if enforce_success and not last_command_successful?
18
+ puts output unless output.empty?
19
+ raise ::GitReview::UnprocessableState
20
+ end
21
+ output
22
+ end
23
+
24
+ # @return [Boolean] whether the last issued system call was successful
25
+ def last_command_successful?
26
+ $?.exitstatus == 0
27
+ end
28
+
29
+ def debug_mode
30
+ ::GitReview::Settings.instance.review_mode == 'debug'
31
+ end
32
+
33
+ # display helper to make output more configurable
34
+ def format_text(info, size)
35
+ info.to_s.gsub("\n", ' ')[0, size-1].ljust(size)
36
+ end
37
+
38
+ # display helper to unify time output
39
+ def format_time(time)
40
+ time = Time.parse(time) if time.is_a?(String)
41
+ time.strftime('%d-%b-%y')
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+
@@ -0,0 +1,227 @@
1
+ module GitReview
2
+
3
+ # The local repository is where the git-review command is being called
4
+ # by default. It is (supposedly) able to handle systems other than Github.
5
+ # TODO: remove Github-dependency
6
+ class Local
7
+
8
+ include ::GitReview::Internals
9
+
10
+ attr_accessor :config
11
+
12
+ # acts like a singleton class but it's actually not
13
+ # use ::GitReview::Local.instance everywhere except in tests
14
+ def self.instance
15
+ @instance ||= new
16
+ end
17
+
18
+ def initialize
19
+ # find root git directory if currently in subdirectory
20
+ if git_call('rev-parse --show-toplevel').strip.empty?
21
+ raise ::GitReview::InvalidGitRepositoryError
22
+ else
23
+ add_pull_refspec
24
+ load_config
25
+ end
26
+ end
27
+
28
+ # @return [Array<String>] all existing branches
29
+ def all_branches
30
+ git_call('branch -a').split("\n").collect { |s| s.strip }
31
+ end
32
+
33
+ # @return [Array<String>] all open requests' branches shouldn't be deleted
34
+ def protected_branches
35
+ github.current_requests.collect { |r| r.head.ref }
36
+ end
37
+
38
+ # @return [Array<String>] all review branches with 'review_' prefix
39
+ def review_branches
40
+ all_branches.collect { |branch|
41
+ # only use uniq branch names (no matter if local or remote)
42
+ branch.split('/').last if branch.include?('review_')
43
+ }.compact.uniq
44
+ end
45
+
46
+ # clean a single request's obsolete branch
47
+ def clean_single(number, force=false)
48
+ request = github.pull_request(source_repo, number)
49
+ if request && request.state == 'closed'
50
+ # ensure there are no unmerged commits or '--force' flag has been set
51
+ branch_name = request.head.ref
52
+ if unmerged_commits?(branch_name) && !force
53
+ puts "Won't delete branches that contain unmerged commits."
54
+ puts "Use '--force' to override."
55
+ else
56
+ delete_branch(branch_name)
57
+ end
58
+ end
59
+ rescue Octokit::NotFound
60
+ false
61
+ end
62
+
63
+ # clean all obsolete branches
64
+ def clean_all
65
+ (review_branches - protected_branches).each do |branch_name|
66
+ # only clean up obsolete branches.
67
+ delete_branch(branch_name) unless unmerged_commits?(branch_name, false)
68
+ end
69
+ end
70
+
71
+ # delete local and remote branches that match a given name
72
+ # @param branch_name [String] name of the branch to delete
73
+ def delete_branch(branch_name)
74
+ delete_local_branch(branch_name)
75
+ delete_remote_branch(branch_name)
76
+ end
77
+
78
+ # delete local branch if it exists.
79
+ # @param (see #delete_branch)
80
+ def delete_local_branch(branch_name)
81
+ if branch_exists?(:local, branch_name)
82
+ git_call("branch -D #{branch_name}", true)
83
+ end
84
+ end
85
+
86
+ # delete remote branch if it exists.
87
+ # @param (see #delete_branch)
88
+ def delete_remote_branch(branch_name)
89
+ if branch_exists?(:remote, branch_name)
90
+ git_call("push origin :#{branch_name}", true)
91
+ end
92
+ end
93
+
94
+ # @param location [Symbol] location of the branch, `:remote` or `:local`
95
+ # @param branch_name [String] name of the branch
96
+ # @return [Boolean] whether a branch exists in a specified location
97
+ def branch_exists?(location, branch_name)
98
+ return false unless [:remote, :local].include?(location)
99
+ prefix = location == :remote ? 'remotes/origin/' : ''
100
+ all_branches.include?(prefix + branch_name)
101
+ end
102
+
103
+ # @return [Boolean] whether there are local changes not committed
104
+ def uncommitted_changes?
105
+ !git_call('diff HEAD').empty?
106
+ end
107
+
108
+ # @param branch_name [String] name of the branch
109
+ # @param verbose [Boolean] if verbose output
110
+ # @return [Boolean] whether there are unmerged commits on the local or
111
+ # remote branch.
112
+ def unmerged_commits?(branch_name, verbose=true)
113
+ locations = []
114
+ locations << '' if branch_exists?(:local, branch_name)
115
+ locations << 'origin/' if branch_exists?(:remote, branch_name)
116
+ locations = locations.repeated_permutation(2).to_a
117
+ if locations.empty?
118
+ puts 'Nothing to do. All cleaned up already.' if verbose
119
+ return false
120
+ end
121
+ # compare remote and local branch with remote and local master
122
+ responses = locations.collect { |loc|
123
+ git_call "cherry #{loc.first}#{target_branch} #{loc.last}#{branch_name}"
124
+ }
125
+ # select commits (= non empty, not just an error message and not only
126
+ # duplicate commits staring with '-').
127
+ unmerged_commits = responses.reject { |response|
128
+ response.empty? or response.include?('fatal: Unknown commit') or
129
+ response.split("\n").reject { |x| x.index('-') == 0 }.empty?
130
+ }
131
+ # if the array ain't empty, we got unmerged commits
132
+ if unmerged_commits.empty?
133
+ false
134
+ else
135
+ puts "Unmerged commits on branch '#{branch_name}'."
136
+ true
137
+ end
138
+ end
139
+
140
+ # @return [Boolean] whether a specified commit has already been merged.
141
+ def merged?(sha)
142
+ not git_call("rev-list #{sha} ^HEAD 2>&1").split("\n").size > 0
143
+ end
144
+
145
+ # @return [String] the source repo
146
+ def source_repo
147
+ github.source_repo
148
+ end
149
+
150
+ # @return [String] the current source branch
151
+ def source_branch
152
+ git_call('branch').chomp.match(/\*(.*)/)[0][2..-1]
153
+ end
154
+
155
+ # @return [String] combine source repo and branch
156
+ def source
157
+ "#{source_repo}/#{source_branch}"
158
+ end
159
+
160
+ # @return [String] the name of the target branch
161
+ def target_branch
162
+ # TODO: Manually override this and set arbitrary branches
163
+ ENV['TARGET_BRANCH'] || 'master'
164
+ end
165
+
166
+ # if to send a pull request to upstream repo, get the parent as target
167
+ # @return [String] the name of the target repo
168
+ def target_repo(upstream=false)
169
+ # TODO: Manually override this and set arbitrary repositories
170
+ if upstream
171
+ github.repository(source_repo).parent.full_name
172
+ else
173
+ source_repo
174
+ end
175
+ end
176
+
177
+ # @return [String] combine target repo and branch
178
+ def target
179
+ "#{target_repo}/#{target_branch}"
180
+ end
181
+
182
+ # @return [String] the head string used for pull requests
183
+ def head
184
+ # in the form of 'user:branch'
185
+ "#{github.github.login}:#{source_branch}"
186
+ end
187
+
188
+ # @return [Boolean] whether already on a feature branch
189
+ def on_feature_branch?
190
+ # If current and target are the same, we are not on a feature branch.
191
+ # If they are different, but we are on master, we should still to switch
192
+ # to a separate branch (since master makes for a poor feature branch).
193
+ source_branch != target_branch && source_branch != 'master'
194
+ end
195
+
196
+ # add remote.origin.fetch to check out pull request locally
197
+ # see {https://help.github.com/articles/checking-out-pull-requests-locally}
198
+ def add_pull_refspec
199
+ refspec = '+refs/pull/*/head:refs/remotes/origin/pr/*'
200
+ fetch_config = "config --local --add remote.origin.fetch #{refspec}"
201
+ git_call(fetch_config, false) unless config_list.include?(refspec)
202
+ end
203
+
204
+ def load_config
205
+ @config = {}
206
+ config_list.split("\n").each do |line|
207
+ key, value = line.split(/=/, 2)
208
+ if @config[key] && @config[key] != value
209
+ @config[key] = [@config[key]].flatten << value
210
+ else
211
+ @config[key] = value
212
+ end
213
+ end
214
+ @config
215
+ end
216
+
217
+ def config_list
218
+ git_call('config --list', false)
219
+ end
220
+
221
+ def github
222
+ @github ||= ::GitReview::Github.instance
223
+ end
224
+
225
+ end
226
+
227
+ end
@@ -0,0 +1,48 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module GitReview
5
+
6
+ class Settings
7
+
8
+ # acts like a singleton class but it's actually not
9
+ # use ::GitReview::Settings.instance everywhere except in tests
10
+ def self.instance
11
+ @instance ||= new
12
+ end
13
+
14
+ # Read settings from ~/.git_review.yml upon initialization.
15
+ def initialize
16
+ @config_file = File.join(Dir.home, '.git_review.yml')
17
+ @config = YAML.load_file(@config_file) if File.exists?(@config_file)
18
+ @config ||= {}
19
+ end
20
+
21
+ # Write settings back to file.
22
+ def save!
23
+ File.open(@config_file, 'w') do |file|
24
+ file.write(YAML.dump(@config))
25
+ end
26
+ end
27
+
28
+ # Allow to access config options.
29
+ def method_missing(method, *args)
30
+ # Determine whether to set or get an attribute.
31
+ if method.to_s =~ /(.*)=$/
32
+ @config[$1.to_sym] = args.shift
33
+ else
34
+ @config[method.to_sym]
35
+ end
36
+ end
37
+
38
+ def respond_to?(method)
39
+ if method.to_s =~ /(.*)=$/ || @config.keys.include?(method.to_sym)
40
+ true
41
+ else
42
+ super
43
+ end
44
+ end
45
+
46
+ end
47
+
48
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-review
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.6
4
+ version: 1.1.7
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-03-05 00:00:00.000000000 Z
12
+ date: 2013-09-04 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: launchy
@@ -32,17 +32,17 @@ dependencies:
32
32
  requirement: !ruby/object:Gem::Requirement
33
33
  none: false
34
34
  requirements:
35
- - - ! '>='
35
+ - - ~>
36
36
  - !ruby/object:Gem::Version
37
- version: '0'
37
+ version: 1.24.0
38
38
  type: :runtime
39
39
  prerelease: false
40
40
  version_requirements: !ruby/object:Gem::Requirement
41
41
  none: false
42
42
  requirements:
43
- - - ! '>='
43
+ - - ~>
44
44
  - !ruby/object:Gem::Version
45
- version: '0'
45
+ version: 1.24.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: yajl-ruby
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -85,6 +85,12 @@ files:
85
85
  - LICENSE
86
86
  - lib/settings.rb
87
87
  - lib/git-review.rb
88
+ - lib/git-review/settings.rb
89
+ - lib/git-review/commands.rb
90
+ - lib/git-review/errors.rb
91
+ - lib/git-review/github.rb
92
+ - lib/git-review/internals.rb
93
+ - lib/git-review/local.rb
88
94
  - lib/oauth_helper.rb
89
95
  - bin/git-review
90
96
  homepage: http://github.com/b4mboo/git-review
@@ -112,3 +118,4 @@ signing_key:
112
118
  specification_version: 3
113
119
  summary: facilitates GitHub code reviews
114
120
  test_files: []
121
+ has_rdoc: