git-review 1.1.6 → 1.1.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: