git_helpers 0.1.0 → 0.2

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,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