-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathgit-unmerged
More file actions
executable file
·337 lines (287 loc) · 8.97 KB
/
git-unmerged
File metadata and controls
executable file
·337 lines (287 loc) · 8.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
#!/usr/bin/env ruby
require 'rubygems'
gem 'term-ansicolor', '>=1.0.5'
require 'term/ansicolor'
class GitCommit
attr_reader :content
def initialize(content)
@content = content
end
def sha
@content.split[1]
end
def to_s
`git log --pretty=format:"%h %ad %an - %s" #{sha}~1..#{sha}`
end
def unmerged?
content =~ /^\+/
end
def equivalent?
content =~ /^\-/
end
end
class GitBranch
attr_reader :name, :commits
def initialize(name, commits)
@name = name
@commits = commits
end
# Returns origin from origin/some/branch/here
def repository
name.split("/", 2).first
end
# Returns some/branch/here from origin/some/branch/here
def branch_name
name.split("/", 2).last
end
def unmerged_commits
commits.select{ |commit| commit.unmerged? }
end
def equivalent_commits
commits.select{ |commit| commit.equivalent? }
end
end
class GitBranches < Array
def self.clean_branch_output(str)
str.split(/\n/).map{ |e| e.strip.gsub(/\*\s+/, '') }.reject{ |branch| branch =~ /\b#{Regexp.escape(UPSTREAM)}\b/ }.sort
end
def self.local_branches
clean_branch_output `git branch`
end
def self.remote_branches
clean_branch_output `git branch -r`
end
def self.load(options)
git_branches = new
branches = if options[:local]
local_branches
elsif options[:remote]
remote_branches
end
branches.each do |branch|
raw_commits = `git cherry -v #{UPSTREAM} #{branch}`.split(/\n/).map{ |c| GitCommit.new(c) }
git_branches << GitBranch.new(branch, raw_commits)
end
git_branches
end
def unmerged
reject{ |branch| branch.commits.empty? }.sort_by{ |branch| branch.name }
end
def any_missing_commits?
select{ |branch| branch.commits.any? }.any?
end
end
class GitUnmerged
VERSION = "1.1"
include Term::ANSIColor
attr_reader :branches
def initialize(args)
@options = {}
@branches_to_prune = []
extract_options_from_args(args)
end
def load
@branches ||= GitBranches.load(:local => local?, :remote => remote?)
@branches.reject!{|b| @options[:exclude].include?(b.name)} if @options[:exclude].is_a?(Array)
@branches.select!{|b| @options[:only].include?(b.name)} if @options[:only].is_a?(Array)
end
def print_overview
load
if @options[:exclude] && @options[:exclude].length > 0
puts "The following branches have been excluded"
@options[:exclude].each do |branch_name|
puts " #{branch_name}"
end
puts
end
if @options[:only] && @options[:only].length > 0
puts "The following branches will be compared against:"
@options[:only].each do |branch_name|
puts " #{branch_name}"
end
puts
end
if branches.any_missing_commits?
puts "The following branches possibly have commits not merged to #{upstream}:"
branches.each do |branch|
num_unmerged = yellow(branch.unmerged_commits.size.to_s)
num_equivalent = green(branch.equivalent_commits.size.to_s)
puts %| #{branch.name} (#{num_unmerged}/#{num_equivalent} commits)|
end
end
end
def print_help
puts <<-EOT.gsub(/^\s+\|/, '')
|Usage: #{$0} [-a] [--upstream <branch>] [--remote] [--prune]
|
|This script wraps the "git cherry" command. It reports the commits from all local
|branches which have not been merged into an upstream branch.
|
| #{yellow("yellow")} commits have not been merged
| #{green("green")} commits have equivalent changes in <upstream> but different SHAs
|
|The default upstream is 'master' (or 'origin/master' if running with --remote)
|
|OPTIONS:
| -a display all unmerged commits (verbose)
| --remote (-r) compare remote branches instead of local branches
| --upstream <branch> specify a specific upstream branch (defaults to master)
| --exclude <branch>[,<branch>,...] specify a comma-separated list of branches to exclude
| --only <branch>[,<branch>,...] specify a comma-separated list of branches to include
| --prune prompts user to delete branches which have no differences with the upstream
|
|EXAMPLE: check for all unmerged commits
| #{$0}
|
|EXAMPLE: check for all unmerged commits and merged commits (but with a different SHA)
| #{$0} -a
|
|EXAMPLE: use a different upstream than master
| #{$0} --upstream otherbranch
|
|EXAMPLE: compare remote branches against origin/master
| #{$0} --remote (-r)
|
|EXAMPLE: delete branches without unmerged commits
| #{$0} --prune
|
|EXAMPLE: delete remote branches without unmerged commits
| #{$0} --remote --prune
|
|GITCONFIG:
| If you name this file git-unmerged and place it somewhere in your PATH
| you will be able to type "git unmerged" to use it. If you'd like to name
| it something else and still refer to it with "git unmerged" then you'll
| need to set up an alias:
| git config --global alias.unmerged \\!#{$0}
|
|Version: #{VERSION}
|Author: Zach Dennis <zdennis@mutuallyhuman.com>
EOT
exit
end
def print_version
puts "#{VERSION}"
end
def branch_description
local? ? "local" : "remote"
end
def print_specifics
load
if branches.any_missing_commits?
print_breakdown
else
puts "There are no #{branch_description} branches out of sync with #{upstream}"
end
end
def print_breakdown
puts "Below is a breakdown for each branch. Here's a legend:"
puts
print_legend
branches.each do |branch|
puts
print "#{branch.name}:"
if branch.unmerged_commits.empty? && !show_equivalent_commits?
print "(no unmerged commits"
if prune?
print ",", red(" this will be pruned")
@branches_to_prune << branch
end
print ")\n"
else
puts
end
branch.unmerged_commits.each { |commit| puts yellow(commit.to_s) }
if show_equivalent_commits?
branch.equivalent_commits.each do |commit|
puts green(commit.to_s)
end
end
end
end
def print_legend
load
puts " " + yellow("yellow") + " commits have not been merged"
puts " " + green("green") + " commits have equivalent changes in #{UPSTREAM} but different SHAs" if show_equivalent_commits?
end
def prune
return unless prune?
if @branches_to_prune.empty?
puts "", "There are no branches to prune."
else
# Protects Heroku repo's
rejected_master_branches = @branches_to_prune.reject!{|branch| branch.branch_name =~ /master/}
puts "", "Are you sure you want to prune the following #{@branches_to_prune.size} branches?", ""
puts red("(Keep in mind this will remove these branches from the remote repository)") if @remote
@branches_to_prune.each do |branch|
puts red(" #{branch.name}")
end
puts "(omitting branches named master)" if rejected_master_branches
puts
print "y or n: "
if STDIN.gets=~/y/i
@branches_to_prune.each do |branch|
if local?
`git branch -D #{branch.name}`
elsif remote?
puts "pruning: #{branch.branch_name} with 'git push #{branch.repository} :#{branch.branch_name}'"
`git push #{branch.repository} :#{branch.branch_name}`
end
end
puts "", "Pruned #{@branches_to_prune.size} branches."
else
puts "", "Pruning aborted. All branches were left unharmed."
end
end
end
def prune? ; @options[:prune ] ; end
def show_help? ; @options[:show_help] ; end
def show_equivalent_commits? ; @options[:show_equivalent_commits] ; end
def show_version? ; @options[:show_version] ; end
def upstream
if @options[:upstream]
@options[:upstream]
elsif local?
"master"
elsif remote?
"origin/master"
end
end
private
def extract_options_from_args(args)
if args.include?("--remote") || args.include?("-r")
@options[:remote] = true
else
@options[:local] = true
end
@options[:prune] = true if args.include?("--prune")
@options[:show_help] = true if args.include?("-h") || args.include?("--help")
@options[:show_equivalent_commits] = true if args.include?("-a")
@options[:show_version] = true if args.include?("-v") || args.include?("--version")
if index=args.index("--upstream")
@options[:upstream] = args[index+1]
end
if index=args.index("--exclude")
@options[:exclude] = args[index+1].split(',')
end
if index=args.index("--only")
@options[:only] = args[index+1].split(',')
end
end
def local? ; @options[:local] ; end
def remote? ; @options[:remote] ; end
end
unmerged = GitUnmerged.new ARGV
UPSTREAM = unmerged.upstream
if unmerged.show_help?
unmerged.print_help
exit
elsif unmerged.show_version?
unmerged.print_version
exit
else
unmerged.print_overview
puts
unmerged.print_specifics
unmerged.prune
end