git_helpers 0.1.0 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,216 @@
1
+ module GitHelpers
2
+ GitBranchError = Class.new(Exception)
3
+ class GitBranch
4
+ attr_accessor :gitdir
5
+ attr_accessor :branch
6
+ attr_writer :infos
7
+
8
+ def initialize(branch="HEAD", dir: ".")
9
+ @gitdir=dir.is_a?(GitDir) ? dir : GitDir.new(dir)
10
+ @branch=branch
11
+ end
12
+
13
+ def new_branch(name)
14
+ self.class.new(name, dir: @gitdir)
15
+ end
16
+
17
+ def to_s
18
+ @branch.to_s
19
+ end
20
+
21
+ def nil?
22
+ @branch.nil?
23
+ end
24
+
25
+ def shellescape
26
+ @branch.shellescape
27
+ end
28
+
29
+ def reset!
30
+ @infos=nil
31
+ end
32
+
33
+ def run(*args,**kw, &b)
34
+ @gitdir.run(*args,**kw, &b)
35
+ end
36
+ def run_simple(*args,**kw,&b)
37
+ @gitdir.run_simple(*args,**kw, &b)
38
+ end
39
+ def run_success(*args,**kw,&b)
40
+ @gitdir.run_success(*args,**kw, &b)
41
+ end
42
+
43
+ def checkout
44
+ branch=@branch
45
+ branch&.delete_prefix!('refs/heads/') #git checkout refs/heads/master check out in a detached head
46
+ SH.sh! "git checkout #{branch}"
47
+ end
48
+ def checkout_detached
49
+ SH.sh! "git checkout #{@branch}~0"
50
+ end
51
+
52
+ def infos(*args, name: :default, detached_name: :detached_default)
53
+ @infos=infos!(*args) unless @infos
54
+ @infos.merge({name: self.name(method: name, detached_method: detached_name)})
55
+ end
56
+
57
+ def infos!(detached: true)
58
+ raise GitBranchError.new("Nil Branch #{self}") if nil?
59
+ infos=branch_infos
60
+
61
+ if infos.nil?
62
+ if !detached #error out
63
+ raise GitBranchError.new("Detached Branch #{self}")
64
+ else
65
+ infos={detached: true}
66
+ return infos
67
+ end
68
+ end
69
+
70
+ type=infos[:type]
71
+ infos[:detached]=false
72
+ if type == :local
73
+ rebase=gitdir.get_config("branch.#{infos["refname:short"]}.rebase")
74
+ rebase = false if rebase.empty?
75
+ rebase = true if rebase == "true"
76
+ infos[:rebase]=rebase
77
+ end
78
+ infos
79
+ end
80
+
81
+ def format_infos(**opts)
82
+ @gitdir.format_branch_infos([infos], **opts)
83
+ end
84
+
85
+ def name(method: :default, detached_method: [:detached_default, :short], shorten: true, highlight_detached: ':', expand_head: true)
86
+ l=lambda { |ev| run_simple(ev, chomp: true, error: :quiet) }
87
+ methods=[*method]
88
+ detached_methods=[*detached_method]
89
+ # we first test each method, then each detached_methods
90
+ method=methods.shift
91
+ if method.nil?
92
+ if !detached_methods.empty?
93
+ describe=self.name(method: detached_methods, detached_method: [], shorten: shorten, highlight_detached: highlight_detached, expand_head: expand_head)
94
+ describe="#{highlight_detached}#{describe}" unless describe.nil? or describe.empty?
95
+ return describe
96
+ else
97
+ return nil
98
+ end
99
+ end
100
+ method="name" if method == :default
101
+ #method="branch-fb" if method == :detached_default
102
+ #method="short" if method == :detached_default
103
+ method="match" if method == :detached_default
104
+ method="branch-fb" if method == :detached_infos
105
+ describe=
106
+ case method.to_s
107
+ when "sha1"
108
+ l.call "git rev-parse #{@branch.shellescape}"
109
+ when "short"
110
+ l.call "git rev-parse --short #{@branch.shellescape}"
111
+ when "symbolic-ref"
112
+ l.call "git symbolic-ref -q --short #{@branch.shellescape}"
113
+ when "describe"
114
+ l.call "git describe #{@branch.shellescape}"
115
+ when "contains"
116
+ l.call "git describe --contains #{@branch.shellescape}"
117
+ when "tags"
118
+ l.call "git describe --tags #{@branch.shellescape}"
119
+ when "match"
120
+ l.call "git describe --all --exact-match #{@branch.shellescape}"
121
+ when "topic"
122
+ l.call "git describe --all #{@branch.shellescape}"
123
+ when "branch"
124
+ l.call "git describe --contains --all #{@branch.shellescape}"
125
+ when "topic-fb" #try --all, then --contains all
126
+ d=l.call "git describe --all #{@branch.shellescape}"
127
+ d=l.call "git describe --contains --all #{@branch.shellescape}" if d.nil? or d.empty?
128
+ d
129
+ when "branch-fb" #try --contains all, then --all
130
+ d=l.call "git describe --contains --all #{@branch.shellescape}"
131
+ d=l.call "git describe --all #{@branch.shellescape}" if d.nil? or d.empty?
132
+ d
133
+ when "magic"
134
+ d1=l.call "git describe --contains --all #{@branch.shellescape}"
135
+ d2=l.call "git describe --all #{@branch.shellescape}"
136
+ d= d1.length < d2.length ? d1 : d2
137
+ d=d1 if d2.empty?
138
+ d=d2 if d1.empty?
139
+ d
140
+ when "name"
141
+ # note: the newer options `git branch --show-current` seems to be
142
+ # the same as this one
143
+ l.call "git rev-parse --abbrev-ref #{@branch.shellescape}"
144
+ when "full_name"
145
+ l.call "git rev-parse --symbolic-full-name #{@branch.shellescape}"
146
+ when "symbolic"
147
+ l.call "git rev-parse --symbolic #{@branch.shellescape}"
148
+ when Proc
149
+ method.call(@branch)
150
+ else
151
+ l.call method unless method.nil? or method.empty?
152
+ end
153
+ if describe.nil? or describe.empty? or describe == "HEAD" && expand_head
154
+ describe=self.name(method: methods, detached_method: detached_method, shorten: shorten, highlight_detached: highlight_detached, expand_head: expand_head)
155
+ end
156
+ if shorten
157
+ describe&.delete_prefix!("refs/")
158
+ describe&.delete_prefix!("heads/")
159
+ end
160
+ return describe
161
+ end
162
+
163
+ def full_name(method: :full_name, detached_method: nil, shorten: false, **opts)
164
+ name(method: method, detached_method: detached_method, shorten: shorten, **opts)
165
+ end
166
+
167
+
168
+ def rebase?
169
+ infos[:rebase]
170
+ end
171
+ def remote
172
+ infos["upstream:remotename"]
173
+ end
174
+ def push_remote
175
+ infos["push:remotename"]
176
+ end
177
+ def upstream(short: true, warn: true)
178
+ # up=%x/git rev-parse --abbrev-ref #{@branch.shellescape}@{u}/.chomp!
179
+ br= short ? infos["upstream:short"] : infos["upstream"]
180
+ if br&.empty?
181
+ br=nil
182
+ warn "Warning: Branch #{self} has no upstream" if warn
183
+ end
184
+ new_branch(br)
185
+ end
186
+ def push(short: true)
187
+ # pu=%x/git rev-parse --abbrev-ref #{@branch.shellescape}@{push}/.chomp!
188
+ br= short ? infos["push:short"] : infos["push"]
189
+ br=nil if br.empty?
190
+ new_branch(br)
191
+ end
192
+ def hash
193
+ infos["objectname"]
194
+ end
195
+
196
+ def ==(other)
197
+ @branch == other.branch && @gitdir=other.gitdir
198
+ end
199
+
200
+ #return upstream + push if push !=upstream
201
+ def related
202
+ up=upstream
203
+ pu=push
204
+ pu=new_branch(nil) if up==pu
205
+ return up, pu
206
+ end
207
+
208
+ def branch_infos
209
+ @gitdir.branch_infos(branch).values.first
210
+ end
211
+
212
+ def ahead_behind(br)
213
+ @gitdir.ahead_behind(@branch,br)
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,178 @@
1
+ module GitHelpers
2
+ # more infos on branches
3
+ module GitBranchInfos
4
+ def ahead_behind(br1, br2)
5
+ with_dir do
6
+ out=run_simple("git rev-list --left-right --count #{br1.shellescape}...#{br2.shellescape}", error: :quiet)
7
+ out.match(/(\d+)\s+(\d+)/) do |m|
8
+ return m[1].to_i, m[2].to_i #br1 is ahead by m[1], behind by m[2] from br2
9
+ end
10
+ return 0, 0
11
+ end
12
+ end
13
+
14
+ def branch_infos(*branches, local: false, remote: false, tags: false, merged: nil, no_merged: nil)
15
+ query = []
16
+ query << "--merged=#{merged.shellescape}" if merged
17
+ query << "--no_merged=#{no_merged.shellescape}" if no_merged
18
+ query += branches.map {|b| name_branch(b)}
19
+ query << 'refs/heads' if local
20
+ query << 'refs/remotes' if remote
21
+ query << 'refs/tags' if tags
22
+ r={}
23
+ format=%w(refname refname:short objecttype objectsize objectname upstream upstream:short upstream:track upstream:remotename upstream:remoteref push push:short push:track push:remotename push:remoteref HEAD symref)
24
+ #Note push:remoteref is buggy (empty if no push refspec specified)
25
+ #and push:track is upstream:track (cf my patch to the git mailing
26
+ #list to correct that)
27
+ out=run_simple("git for-each-ref --format '#{format.map {|f| "%(#{f})"}.join(';')}' #{query.shelljoin}", chomp: :lines)
28
+ out.each do |l|
29
+ infos=l.split(';')
30
+ full_name=infos[0]
31
+ infos=Hash[format.zip(infos)]
32
+
33
+ infos[:name]=infos["refname:short"]
34
+ infos[:head]=!(infos["HEAD"].empty? or infos["HEAD"]==" ")
35
+
36
+ type=if full_name.start_with?("refs/heads/")
37
+ :local
38
+ elsif full_name.start_with?("refs/remotes/")
39
+ :remote
40
+ elsif full_name.start_with?("refs/tags/")
41
+ :tags
42
+ end
43
+ name = case type
44
+ when :local
45
+ full_name.delete_prefix("refs/heads/")
46
+ when :remote
47
+ full_name.delete_prefix("refs/remotes/")
48
+ when :tags
49
+ full_name.delete_prefix("refs/tags/")
50
+ end
51
+ infos[:type]=type
52
+ infos[:name]=name
53
+
54
+ infos[:upstream_ahead]=0
55
+ infos[:upstream_behind]=0
56
+ infos[:push_ahead]=0
57
+ infos[:push_behind]=0
58
+ track=infos["upstream:track"]
59
+ track.match(/ahead (\d+)/) do |m|
60
+ infos[:upstream_ahead]=m[1].to_i
61
+ end
62
+ track.match(/behind (\d+)/) do |m|
63
+ infos[:upstream_behind]=m[1].to_i
64
+ end
65
+
66
+ ## git has a bug for push:track
67
+ # ptrack=infos["push:track"]
68
+ # ptrack.match(/ahead (\d+)/) do |m|
69
+ # infos[:push_ahead]=m[1].to_i
70
+ # end
71
+ # ptrack.match(/behind (\d+)/) do |m|
72
+ # infos[:push_behind]=m[1].to_i
73
+ # end
74
+ unless infos["push"].empty?
75
+ ahead, behind=ahead_behind(infos["refname"], infos["push"])
76
+ infos[:push_ahead]=ahead
77
+ infos[:push_behind]=behind
78
+ end
79
+
80
+ origin = infos["upstream:remotename"]
81
+ unless origin.empty?
82
+ upstream_short=infos["upstream:short"]
83
+ infos["upstream:name"]=upstream_short.delete_prefix(origin+"/")
84
+ end
85
+ pushorigin = infos["push:remotename"]
86
+ unless pushorigin.empty?
87
+ push_short=infos["push:short"]
88
+ if push_short.empty?
89
+ infos["push:name"]=infos["refname:short"]
90
+ else
91
+ infos["push:name"]= push_short.delete_prefix(pushorigin+"/")
92
+ end
93
+ end
94
+
95
+ r[full_name]=infos
96
+ end
97
+ r
98
+ end
99
+
100
+ def format_branch_infos(infos, compare: nil, merged: nil, cherry: false, log: false)
101
+ # warning, here we pass the info values, ie infos should be a list
102
+ infos.each do |i|
103
+ name=i["refname:short"]
104
+ upstream=i["upstream:short"]
105
+ push=i["push:short"]
106
+ color=:magenta
107
+ if merged
108
+ color=:red #not merged
109
+ [*merged].each do |br|
110
+ ahead, _behind=ahead_behind(i["refname"], br)
111
+ if ahead==0
112
+ color=:magenta
113
+ break
114
+ end
115
+ end
116
+ end
117
+ r="#{i["HEAD"]}#{name.color(color)}"
118
+ if compare
119
+ ahead, behind=ahead_behind(i["refname"], compare)
120
+ r << "↑#{ahead}" unless ahead==0
121
+ r << "↓#{behind}" unless behind==0
122
+ end
123
+ unless upstream.empty?
124
+ r << " @{u}"
125
+ r << "=@{push}" if push==upstream
126
+ r << "=#{upstream.color(:yellow)}"
127
+ r << "↑#{i[:upstream_ahead]}" unless i[:upstream_ahead]==0
128
+ r << "↓#{i[:upstream_behind]}" unless i[:upstream_behind]==0
129
+ end
130
+ unless push.empty? or push == upstream
131
+ r << " @{push}=#{push.color(:yellow)}"
132
+ r << "↑#{i[:push_ahead]}" unless i[:push_ahead]==0
133
+ r << "↓#{i[:push_behind]}" unless i[:push_behind]==0
134
+ end
135
+ if log
136
+ log_options=case log
137
+ when Hash
138
+ log.map {|k,v| "--#{k}=#{v.shellescape}"}.join(' ')
139
+ when String
140
+ log
141
+ else
142
+ ""
143
+ end
144
+ r << " → "+run_simple("git -c color.ui=always log --date=human --oneline --no-walk #{log_options} #{name}")
145
+ end
146
+ puts r
147
+ if cherry #todo: add push cherry?
148
+ if upstream and i[:upstream_ahead] != 0 || i[:upstream_behind] != 0
149
+ ch=run_simple("git -c color.ui=always log --left-right --topo-order --oneline #{name}...#{upstream}")
150
+ ch.each_line do |l|
151
+ puts " #{l}"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def name_branch(branch='HEAD',**args)
159
+ self.branch(branch).full_name(**args)
160
+ end
161
+ def name(branch='HEAD',**args)
162
+ self.branch(branch).name(**args)
163
+ end
164
+
165
+ #return all local upstreams of branches, recursively
166
+ def recursive_upstream(*branches, local: true)
167
+ require 'tsort'
168
+ each_node=lambda do |&b| branches.each(&b) end
169
+ each_child=lambda do |br, &b|
170
+ upstream=branch(br).upstream(short: false)
171
+ upstreams=[]
172
+ upstreams << upstream.to_s unless upstream.nil? or local && upstream.to_s.start_with?("refs/remotes/")
173
+ upstreams.each(&b)
174
+ end
175
+ TSort.tsort(each_node, each_child)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env ruby
2
+ # Inspired by diff-so-fancy; wrapper around diff-highlight
3
+ # https://github.com/stevemao/diff-so-fancy
4
+ # [commit: 0ea7c129420c57ec0384a704325e27c41f8f450d,
5
+ # last commit checked: 3adf0114da99643ec53a16253a3d6f42390e4c19 (2017-04-04)]
6
+ #TODO: use git-config
7
+ #TODO: work with 'git log -p --graph'
8
+
9
+ require "simplecolor"
10
+ SimpleColor.mix_in_string
11
+ begin
12
+ require "shell_helpers"
13
+ rescue LoadError
14
+ end
15
+
16
+ module GitHelpers
17
+ class GitDiff
18
+ def self.output(gdiff, **opts)
19
+ if gdiff.respond_to?(:each_line)
20
+ enum=gdiff.each_line
21
+ else
22
+ enum=gdiff.each
23
+ end
24
+ self.new(enum, **opts).output
25
+ end
26
+
27
+ attr_reader :output
28
+ include Enumerable
29
+ NoNewLine="\\n"
30
+
31
+ def initialize(diff,**opts)
32
+ @diff=diff #Assume diff is a line iterator ['gitdiff'.each_line]
33
+ @current=0
34
+ @mode=:unknown
35
+ @opts=opts
36
+ @opts[:color]=@opts.fetch(:color,true)
37
+ #modes:
38
+ #- unknown (temp mode)
39
+ #- commit
40
+ #- meta
41
+ #- submodule_header
42
+ #- submodule
43
+ #- diff_header
44
+ #- hunk
45
+ @colors={meta: [:bold]}
46
+ end
47
+
48
+ def output_line(l)
49
+ @output << l.chomp+"\n"
50
+ end
51
+ def output_lines(lines)
52
+ lines.each {|l| output_line l}
53
+ end
54
+ def output
55
+ each {|l| puts l}
56
+ end
57
+
58
+ def next_mode(nmode)
59
+ @next_mode=nmode
60
+ end
61
+ def update_mode
62
+ @start_mode=false
63
+ @next_mode && change_mode(@next_mode)
64
+ @next_mode=nil
65
+ end
66
+ def change_mode(nmode)
67
+ @start_mode=true
68
+ send :"end_#{@mode}" unless @mode==:unknown
69
+ @mode=nmode
70
+ send :"new_#{@mode}" unless @mode==:unknown
71
+ end
72
+
73
+ def new_commit; @commit={}; end
74
+ def end_commit; end
75
+ def new_meta; end
76
+ def end_meta; end
77
+ def new_hunk; end
78
+ def end_hunk; end
79
+ def new_submodule_header; @submodule={}; end
80
+ def end_submodule_header; end
81
+ def new_submodule; end
82
+ def end_submodule; end
83
+ def new_diff_header; @file={mode: :modify} end
84
+ def end_diff_header; end
85
+
86
+ def detect_new_diff_header
87
+ @line =~ /^diff\s/
88
+ end
89
+ def detect_end_diff_header
90
+ @line =~ /^\+\+\+\s/
91
+ end
92
+
93
+ def detect_new_hunk
94
+ @line.match(/^@@+\s.*\s@@/)
95
+ end
96
+ def detect_end_hunk
97
+ @hunk[:lines_seen].each_with_index.all? { |v,i| v==@hunk[:lines][i].first }
98
+ end
99
+
100
+ def handle_meta
101
+ handle_line
102
+ end
103
+
104
+ def parse_hunk_header
105
+ m=@line.match(/^@@+\s(.*)\s@@\s*(.*)/)
106
+ hunks=m[1]
107
+ @hunk={lines: []}
108
+ @hunk[:header]=m[2]
109
+ filenumber=0
110
+ hunks.split.each do |hunk|
111
+ hunkmode=hunk[0]
112
+ hunk=hunk[1..-1]
113
+ line,length=hunk.split(',').map(&:to_i)
114
+ #handle hunks of the form @@ -1 +0,0 @@
115
+ length,line=line,length unless length
116
+ case hunkmode
117
+ when '-'
118
+ filenumber+=1
119
+ @hunk[:lines][filenumber]=[length,line]
120
+ when '+'
121
+ @hunk[:lines][0]=[length,line]
122
+ end
123
+ end
124
+ @hunk[:n]=@hunk[:lines].length
125
+ @hunk[:lines_seen]=Array.new(@hunk[:n],0)
126
+ end
127
+
128
+ def handle_hunk
129
+ if @start_mode
130
+ parse_hunk_header
131
+ else
132
+ #'The 'No new line at end of file' is sort of part of the hunk, but
133
+ #is not considerer in the hunkheader
134
+ unless @line == NoNewLine
135
+ #we need to wait for a NoNewLine to be sure we are at the end of the hunk
136
+ return reparse(:unknown) if detect_end_hunk
137
+ linemodes=@line[0...@hunk[:n]-1]
138
+ newline=true
139
+ #the line is on the new file unless there is a '-' somewhere
140
+ if linemodes=~/-/
141
+ newline=false
142
+ else
143
+ @hunk[:lines_seen][0]+=1
144
+ end
145
+ (1...@hunk[:n]).each do |i|
146
+ linemode=linemodes[i-1]
147
+ case linemode
148
+ when '-'
149
+ @hunk[:lines_seen][i]+=1
150
+ when ' '
151
+ @hunk[:lines_seen][i]+=1 if newline
152
+ end
153
+ end
154
+ end
155
+ end
156
+ handle_line
157
+ end
158
+
159
+ def get_file_name(file)
160
+ #remove prefix (todo handle the no-prefix option)
161
+ file.gsub(/^[abciow12]\//,'')
162
+ end
163
+
164
+ def detect_filename
165
+ if m=@line.match(/^---\s(.*)/)
166
+ @file[:old_name]=get_file_name(m[1])
167
+ return true
168
+ end
169
+ if m=@line.match(/^\+\+\+\s(.*)/)
170
+ @file[:name]=get_file_name(m[1])
171
+ return true
172
+ end
173
+ false
174
+ end
175
+
176
+ def detect_perm
177
+ if m=@line.match(/^old mode\s+(.*)/)
178
+ @file[:old_perm]=m[1]
179
+ return true
180
+ end
181
+ if m=@line.match(/^new mode\s+(.*)/)
182
+ @file[:new_perm]=m[1]
183
+ return true
184
+ end
185
+ false
186
+ end
187
+
188
+ def detect_index
189
+ if m=@line.match(/^index\s+(.*)\.\.(.*)/)
190
+ @file[:oldhash]=m[1].split(',')
191
+ @file[:hash],perm=m[2].split
192
+ @file[:perm]||=perm
193
+ return true
194
+ end
195
+ false
196
+ end
197
+
198
+ def detect_delete
199
+ if m=@line.match(/^deleted file mode\s+(.*)/)
200
+ @file[:old_perm]=m[1]
201
+ @file[:mode]=:delete
202
+ return true
203
+ end
204
+ false
205
+ end
206
+
207
+ def detect_newfile
208
+ if m=@line.match(/^new file mode\s+(.*)/)
209
+ @file[:new_perm]=m[1]
210
+ @file[:mode]=:new
211
+ return true
212
+ end
213
+ false
214
+ end
215
+
216
+ def detect_rename_copy
217
+ if m=@line.match(/^similarity index\s+(.*)/)
218
+ @file[:similarity]=m[1]
219
+ return true
220
+ end
221
+ if m=@line.match(/^dissimilarity index\s+(.*)/)
222
+ @file[:mode]=:rewrite
223
+ @file[:dissimilarity]=m[1]
224
+ return true
225
+ end
226
+ #if we have a rename with 100% similarity, there won't be any hunks so
227
+ #we need to detect the filenames there
228
+ if m=@line.match(/^(?:rename|copy) from\s+(.*)/)
229
+ @file[:old_name]=m[1]
230
+ end
231
+ if m=@line.match(/^(?:rename|copy) to\s+(.*)/)
232
+ @file[:name]=m[1]
233
+ end
234
+ if m=@line.match(/^rename\s+(.*)/)
235
+ @file[:mode]=:rename
236
+ return true
237
+ end
238
+ if m=@line.match(/^copy\s+(.*)/)
239
+ @file[:mode]=:copy
240
+ return true
241
+ end
242
+ false
243
+ end
244
+
245
+ def detect_diff_header
246
+ if @start_mode
247
+ if m=@line.chomp.match(/^diff\s--git\s(.*)\s(.*)/)
248
+ @file[:old_name]=get_file_name(m[1])
249
+ @file[:name]=get_file_name(m[2])
250
+ elsif
251
+ m=@line.match(/^diff\s--(?:cc|combined)\s(.*)/)
252
+ @file[:name]=get_file_name(m[1])
253
+ end
254
+ true
255
+ end
256
+ end
257
+
258
+ def handle_diff_header
259
+ if detect_diff_header
260
+ elsif detect_filename
261
+ elsif detect_perm
262
+ elsif detect_index
263
+ elsif detect_delete
264
+ elsif detect_newfile
265
+ elsif detect_rename_copy
266
+ else
267
+ return reparse(:unknown)
268
+ end
269
+ next_mode(:unknown) if detect_end_diff_header
270
+ handle_line
271
+ end
272
+
273
+ def detect_new_submodule_header
274
+ if m=@line.chomp.match(/^Submodule\s(.*)\s(.*)/)
275
+ subname=m[1];
276
+ return not(@submodule && @submodule[:name]==subname)
277
+ end
278
+ false
279
+ end
280
+
281
+ def handle_submodule_header
282
+ if m=@line.chomp.match(/^Submodule\s(\S*)\s(.*)/)
283
+ subname=m[1]
284
+ if @submodule[:name]
285
+ #we may be dealing with a new submodule
286
+ #require 'pry'; binding.pry
287
+ return reparse(:submodule_header) if subname != @submodule[:name]
288
+ else
289
+ @submodule[:name]=m[1]
290
+ end
291
+ subinfo=m[2]
292
+ if subinfo == "contains untracked content"
293
+ @submodule[:untracked]=true
294
+ elsif subinfo == "contains modified content"
295
+ @submodule[:modified]=true
296
+ else
297
+ (@submodule[:info]||="") << subinfo
298
+ next_mode(:submodule) if subinfo =~ /^.......\.\.\.?........*:$/
299
+ end
300
+ handle_line
301
+ else
302
+ return reparse(:unknown)
303
+ end
304
+ end
305
+
306
+ def submodule_line
307
+ @line=~/^ [><] /
308
+ end
309
+
310
+ def handle_submodule
311
+ #we have lines indicating new commits
312
+ #they always end by a new line except when followed by another submodule
313
+ return reparse(:unknown) if !submodule_line
314
+ handle_line
315
+ end
316
+
317
+ def detect_new_commit
318
+ @line=~/^commit\b/
319
+ end
320
+
321
+ def handle_commit
322
+ if m=@line.match(/^(\w+):\s(.*)/)
323
+ @commit[m[1]]=m[2]
324
+ handle_line
325
+ else
326
+ @start_mode ? handle_line : reparse(:unknown)
327
+ end
328
+ end
329
+
330
+ def reparse(nmode)
331
+ change_mode(nmode)
332
+ parse_line
333
+ end
334
+
335
+ def handle_line
336
+ end
337
+
338
+
339
+ def parse_line
340
+ case @mode
341
+ when :unknown, :meta
342
+ if detect_new_hunk
343
+ return reparse(:hunk)
344
+ elsif detect_new_diff_header
345
+ return reparse(:diff_header)
346
+ elsif detect_new_submodule_header
347
+ return reparse(:submodule_header)
348
+ elsif detect_new_commit
349
+ return reparse(:commit)
350
+ else
351
+ change_mode(:meta) if @mode==:unknown
352
+ handle_meta
353
+ end
354
+ when :commit
355
+ handle_commit
356
+ when :submodule_header
357
+ handle_submodule_header
358
+ when :submodule
359
+ handle_submodule
360
+ when :diff_header
361
+ handle_diff_header
362
+ #=> mode=unknown if we detect we are not a diff header anymore
363
+ when :hunk
364
+ handle_hunk
365
+ #=> mode=unknown at end of hunk
366
+ end
367
+ end
368
+
369
+ def prepare_new_line(line)
370
+ @orig_line=line
371
+ @line=@orig_line.uncolor
372
+ update_mode
373
+ end
374
+
375
+ def parse
376
+ Enumerator.new do |y|
377
+ @output=y
378
+ @diff.each do |line|
379
+ prepare_new_line(line)
380
+ parse_line
381
+ yield if block_given?
382
+ end
383
+ change_mode(:unknown) #to trigger the last end_* hook
384
+ end
385
+ end
386
+
387
+ def each(&b)
388
+ parse.each(&b)
389
+ end
390
+ end
391
+
392
+ class GitDiffDebug < GitDiff
393
+ def initialize(*args,&b)
394
+ super
395
+ @cols=`tput cols`.to_i
396
+ end
397
+
398
+ def center(msg)
399
+ msg.center(@cols,'─')
400
+ end
401
+
402
+ def handle_line
403
+ super
404
+ output_line "#{@mode}: #{@orig_line}"
405
+ #p @hunk if @mode==:hunk
406
+ end
407
+
408
+ %i(commit meta diff_header hunk submodule_header submodule).each do |meth|
409
+ define_method(:"new_#{meth}") do |*a,&b|
410
+ super(*a,&b)
411
+ output_line(center("New #{meth}"))
412
+ end
413
+ define_method(:"end_#{meth}") do |*a,&b|
414
+ super(*a,&b)
415
+ output_line(center("End #{meth}"))
416
+ end
417
+ end
418
+ end
419
+
420
+ #stolen from diff-highlight git contrib script
421
+ class GitDiffHighlight < GitDiff
422
+ def new_hunk
423
+ super
424
+ @accumulator=[[],[]]
425
+ end
426
+ def end_hunk
427
+ super
428
+ show_hunk
429
+ end
430
+
431
+ def highlight_pair(old,new)
432
+ oldc=SimpleColor.color_entities(old).each_with_index
433
+ newc=SimpleColor.color_entities(new).each_with_index
434
+ seen_pm=false
435
+ #find common prefix
436
+ loop do
437
+ a=oldc.grep {|c| ! SimpleColor.color?(c)}
438
+ b=newc.grep {|c| ! SimpleColor.color?(c)}
439
+ if !seen_pm and a=="-" and b=="+"
440
+ seen_pm=true
441
+ elsif a==b
442
+ else
443
+ last
444
+ end
445
+ #rescue StopIteration
446
+ end
447
+ end
448
+
449
+ def show_hunk
450
+ old,new=@accumulator
451
+ if old.length != new.length
452
+ output_lines(old+new)
453
+ else
454
+ newhunk=[]
455
+ (0...old.length).each do |i|
456
+ oldi,newi=highlight_pair(old[i],new[i])
457
+ output_line oldi
458
+ newhunk << newi
459
+ end
460
+ output_lines(newhunk)
461
+ end
462
+ end
463
+
464
+ def handle_line
465
+ if @mode == :hunk && @hunk[:n]==2
466
+ linemode=@line[0]
467
+ case linemode
468
+ when "-"
469
+ @accumulator[0] << @orig_line
470
+ when "+"
471
+ @accumulator[1] << @orig_line
472
+ else
473
+ show_hunk
474
+ @accumulator=[[],[]]
475
+ output_line @orig_line
476
+ end
477
+ else
478
+ output_line @orig_line
479
+ end
480
+ end
481
+ end
482
+
483
+ class GitFancyDiff < GitDiff
484
+
485
+ def initialize(*args,**kw,&b)
486
+ super
487
+ #when run inside a pager I get one more column so the line overflow
488
+ #I don't know why
489
+ cols=`tput cols`.to_i
490
+ cols==0 && cols=80 #if TERM is not defined `tput cols` returns ''
491
+ @cols=cols-1
492
+ end
493
+
494
+ def hline
495
+ '─'*@cols
496
+ end
497
+ def hhline
498
+ #'⬛'*@cols
499
+ #"━"*@cols
500
+ "═"*@cols
501
+ end
502
+
503
+ def short_perm_mode(m, prefix: '+')
504
+ case m
505
+ when "040000"
506
+ prefix+"d" #directory
507
+ when "100644"
508
+ "" #file
509
+ when "100755"
510
+ prefix+"x" #executable
511
+ when "120000"
512
+ prefix+"l" #symlink
513
+ when "160000"
514
+ prefix+"g" #gitlink
515
+ end
516
+ end
517
+ def perm_mode(m, prefix: ' ')
518
+ case m
519
+ when "040000"
520
+ prefix+"directory"
521
+ when "100644"
522
+ "" #file
523
+ when "100755"
524
+ prefix+"executable"
525
+ when "120000"
526
+ prefix+"symlink"
527
+ when "160000"
528
+ prefix+"gitlink"
529
+ end
530
+ end
531
+
532
+ def diff_header_summary
533
+ r=case @file[:mode]
534
+ when :modify
535
+ "modified: #{@file[:name]}"
536
+ when :rewrite
537
+ "rewrote: #{@file[:name]} (dissimilarity: #{@file[:dissimilarity]})"
538
+ when :new
539
+ "added#{perm_mode(@file[:new_perm])}: #{@file[:name]}"
540
+ when :delete
541
+ "deleted#{perm_mode(@file[:old_perm])}: #{@file[:old_name]}"
542
+ when :rename
543
+ "renamed: #{@file[:old_name]} to #{@file[:name]} (similarity: #{@file[:similarity]})"
544
+ when :copy
545
+ "copied: #{@file[:old_name]} to #{@file[:name]} (similarity: #{@file[:similarity]})"
546
+ end
547
+ r<<" [#{short_perm_mode(@file[:old_perm],prefix:'-')}#{short_perm_mode(@file[:new_perm])}]" if @file[:old_perm] && @file[:new_perm]
548
+ r
549
+ end
550
+
551
+ def meta_colorize(l)
552
+ if @opts[:color]
553
+ l.color(*@colors[:meta])
554
+ else
555
+ l
556
+ end
557
+ end
558
+
559
+ def new_diff_header
560
+ super
561
+ output_line meta_colorize(hline)
562
+ end
563
+
564
+ def end_diff_header
565
+ super
566
+ output_line meta_colorize(diff_header_summary)
567
+ output_line meta_colorize(hline)
568
+ end
569
+
570
+ def submodule_header_summary
571
+ r="Submodule #{@submodule[:name]}"
572
+ extra=[@submodule[:modified] && "modified", @submodule[:untracked] && "untracked"].compact.join("+")
573
+ r<<" [#{extra}]" unless extra.empty?
574
+ r << " #{@submodule[:info]}" if @submodule[:info]
575
+ r
576
+ end
577
+
578
+ def new_submodule_header
579
+ super
580
+ output_line meta_colorize(hline)
581
+ end
582
+
583
+ def end_submodule_header
584
+ super
585
+ output_line meta_colorize(submodule_header_summary)
586
+ output_line meta_colorize(hline)
587
+ end
588
+
589
+ def nonewline_clean
590
+ @mode==:hunk && @file && (@file[:perm]=="120000" or @file[:old_perm]=="120000" or @file[:new_perm]=="120000") && @line==NoNewLine
591
+ end
592
+
593
+ def new_commit
594
+ super
595
+ output_line meta_colorize(hhline)
596
+ end
597
+ def end_commit
598
+ super
599
+ output_line meta_colorize(hhline)
600
+ end
601
+
602
+ def clean_hunk_col
603
+ if @opts[:color] && @mode==:hunk && !@start_mode && @hunk[:n]==2
604
+ bcolor,ecolor,line=SimpleColor.current_colors(@orig_line)
605
+ m=line.scrub.match(/^([+-])?(.*)/)
606
+ mode=m[1]
607
+ cline=m[2]
608
+ if mode && cline !~ /[^[:space:]]/ #detect blank line
609
+ output_line SimpleColor.color(bcolor.to_s + (cline.empty? ? " ": cline)+ecolor.to_s,:inverse)
610
+ else
611
+ cline.sub!(/^\s/,'') unless mode #strip one blank character
612
+ output_line bcolor.to_s+cline+ecolor.to_s
613
+ end
614
+ true
615
+ end
616
+ end
617
+
618
+ def hunk_header
619
+ if @mode==:hunk && @start_mode
620
+ if @hunk[:lines][0][1] && @hunk[:lines][0][1] != 0
621
+ header="#{@file[:name]}:#{@hunk[:lines][0][1]}"
622
+ output_line @orig_line.sub(/(@@+\s)(.*)(\s@@+)/,"\\1#{header}\\3")
623
+ end
624
+ true
625
+ end
626
+ end
627
+
628
+ def binary_file_differ
629
+ @file and (@file[:mode]==:new && @line =~ %r{^Binary files /dev/null and ./#{@file[:name]} differ$} or
630
+ @file[:mode]==:delete && @line =~ %r{^Binary files ./#{@file[:old_name]} and /dev/null differ$})
631
+ end
632
+
633
+ def handle_line
634
+ super
635
+ #:diff_header and submodule_header are handled at end_*
636
+ case @mode
637
+ when :meta
638
+ if binary_file_differ
639
+ else output_line @orig_line
640
+ end
641
+ when :hunk
642
+ if hunk_header
643
+ elsif nonewline_clean
644
+ elsif clean_hunk_col
645
+ else output_line @orig_line
646
+ end
647
+ when :submodule,:commit
648
+ output_line @orig_line
649
+ end
650
+ end
651
+ end
652
+ end
653
+
654
+ if __FILE__ == $0
655
+ require 'optparse'
656
+
657
+ @opts={pager: true, diff_highlight: true, color: true, debug: false}
658
+ optparse = OptionParser.new do |opt|
659
+ opt.banner = "fancy git diff"
660
+ opt.on("--[no-]pager", "launch the pager [true]") do |v|
661
+ @opts[:pager]=v
662
+ end
663
+ opt.on("--[no-]highlight", "run the diff through diff-highlight [true]") do |v|
664
+ @opts[:diff_highlight]=v
665
+ end
666
+ opt.on("--[no-]color", "color output [true]") do |v|
667
+ @opts[:color]=v
668
+ end
669
+ opt.on("--raw", "Only parse diff headers") do |v|
670
+ @opts[:color]=false
671
+ @opts[:pager]=false
672
+ @opts[:diff_highlight]=false
673
+ end
674
+ opt.on("--[no-]debug", "Debug mode") do |v|
675
+ @opts[:debug]=v
676
+ end
677
+ end
678
+ optparse.parse!
679
+ @opts[:pager]=false unless Module.const_defined?('ShellHelpers')
680
+ @opts[:pager] && ShellHelpers.run_pager #("--pattern '^(Date|added|deleted|modified): '")
681
+
682
+ diff_highlight=ENV['DIFF_HIGHLIGHT']||"#{File.dirname(__FILE__)}/contrib/diff-highlight/diff-highlight"
683
+
684
+ args=ARGF
685
+ if @opts[:debug]
686
+ GitHelpers::GitDiffDebug.new(args,**@opts).output
687
+ elsif @opts[:diff_highlight]
688
+ IO.popen(diff_highlight,'r+') do |f|
689
+ Thread.new do
690
+ args.each_line do |l|
691
+ f.write(l)
692
+ end
693
+ f.close_write
694
+ end
695
+ GitHelpers::GitFancyDiff.new(f,**@opts).output
696
+ end
697
+ else
698
+ #diff=GitDiffHighlight.new(args,**@opts).parse
699
+ GitHelpers::GitFancyDiff.new(args,**@opts).output
700
+ end
701
+ end