git_helpers 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/gemspec.yml ADDED
@@ -0,0 +1,18 @@
1
+ name: git_helpers
2
+ summary: "Git helpers utilities"
3
+ description: "Add status line 'gitstatus.rb' and diff enhancement 'diff-fancy.rb'"
4
+ license: MIT
5
+ authors: Damien Robert
6
+ email: Damien.Olivier.Robert+gems@gmail.com
7
+ homepage: https://github.com/DamienRobert/git_helpers#readme
8
+
9
+ dependencies:
10
+ shell_helpers: ~> 0.1
11
+ drain: ~> 0.1
12
+ simplecolor: ~> 0.2
13
+ development_dependencies:
14
+ bundler: "~> 1.10"
15
+ minitest: "~> 5.0"
16
+ rake: "~> 10"
17
+ rubygems-tasks: "~> 0.2"
18
+ yard: "~> 0.8"
@@ -0,0 +1,71 @@
1
+ # encoding: utf-8
2
+
3
+ require 'yaml'
4
+
5
+ Gem::Specification.new do |gem|
6
+ gemspec = YAML.load_file('gemspec.yml')
7
+
8
+ gem.name = gemspec.fetch('name')
9
+ gem.version = gemspec.fetch('version') do
10
+ lib_dir = File.join(File.dirname(__FILE__),'lib')
11
+ $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'git_helpers/version'
14
+ GitHelpers::VERSION
15
+ end
16
+
17
+ gem.summary = gemspec['summary']
18
+ gem.description = gemspec['description']
19
+ gem.licenses = Array(gemspec['license'])
20
+ gem.authors = Array(gemspec['authors'])
21
+ gem.email = gemspec['email']
22
+ gem.homepage = gemspec['homepage']
23
+
24
+ glob = lambda { |patterns| gem.files & Dir[*patterns] }
25
+
26
+ gem.files = `git ls-files`.split($/)
27
+
28
+ `git submodule --quiet foreach --recursive pwd`.split($/).each do |submodule|
29
+ submodule.sub!("#{Dir.pwd}/",'')
30
+
31
+ Dir.chdir(submodule) do
32
+ `git ls-files`.split($/).map do |subpath|
33
+ gem.files << File.join(submodule,subpath)
34
+ end
35
+ end
36
+ end
37
+ gem.files = glob[gemspec['files']] if gemspec['files']
38
+
39
+ gem.executables = gemspec.fetch('executables') do
40
+ glob['bin/*'].map { |path| File.basename(path) }
41
+ end
42
+ gem.default_executable = gem.executables.first if Gem::VERSION < '1.7.'
43
+
44
+ gem.extensions = glob[gemspec['extensions'] || 'ext/**/extconf.rb']
45
+ gem.extra_rdoc_files = glob[gemspec['extra_doc_files'] || '*.{txt,md}']
46
+
47
+ gem.require_paths = Array(gemspec.fetch('require_paths') {
48
+ %w[ext lib].select { |dir| File.directory?(dir) }
49
+ })
50
+
51
+ gem.requirements = Array(gemspec['requirements'])
52
+ gem.required_ruby_version = gemspec['required_ruby_version']
53
+ gem.required_rubygems_version = gemspec['required_rubygems_version']
54
+ gem.post_install_message = gemspec['post_install_message']
55
+
56
+ split = lambda { |string| string.split(/,\s*/) }
57
+
58
+ if gemspec['dependencies']
59
+ gemspec['dependencies'].each do |name,versions|
60
+ gem.add_dependency(name,split[versions])
61
+ end
62
+ end
63
+
64
+ if gemspec['development_dependencies']
65
+ gemspec['development_dependencies'].each do |name,versions|
66
+ gem.add_development_dependency(name,split[versions])
67
+ end
68
+ end
69
+
70
+ gem.metadata['yard.run']='yri'
71
+ end
@@ -0,0 +1,344 @@
1
+ require 'git_helpers/version'
2
+ require 'git_helpers/extra_helpers'
3
+ require 'dr/base/bool'
4
+ require 'pathname'
5
+
6
+ module GitHelpers
7
+ #git functions helper
8
+
9
+ #small library wrapping git; use rugged for more interesting things
10
+ class GitDir
11
+ attr_accessor :dir
12
+ def initialize(dir=".")
13
+ @dir=Pathname.new(dir.to_s).realpath
14
+ end
15
+
16
+ def to_s
17
+ @dir.to_s
18
+ end
19
+
20
+
21
+ #we could also use 'git -C #{@dir}' for each git invocation
22
+ def with_dir
23
+ Dir.chdir(@dir) { yield }
24
+ end
25
+
26
+ def all_files
27
+ with_dir do
28
+ %x/git ls-files -z/.split("\0")
29
+ end
30
+ end
31
+
32
+ #are we in a git folder?
33
+ def git?(quiet: false)
34
+ launch="git rev-parse"
35
+ launch=launch + " 2>/dev/null" if quiet
36
+ with_dir do
37
+ system launch
38
+ return Bool.to_bool($?)
39
+ end
40
+ end
41
+
42
+ #are we in .git/?
43
+ def gitdir?
44
+ with_dir do
45
+ return Bool.to_bool(%x/git rev-parse --is-inside-git-dir/)
46
+ end
47
+ end
48
+ #are we in the worktree?
49
+ def worktree?
50
+ with_dir do
51
+ return Bool.to_bool(%x/git rev-parse --is-inside-work-tree/)
52
+ end
53
+ end
54
+ #are we in a bare repo?
55
+ def bare?
56
+ with_dir do
57
+ return Bool.to_bool(%x/git rev-parse --is-bare-repository/)
58
+ end
59
+ end
60
+
61
+ #return the absolute path of the toplevel
62
+ def toplevel
63
+ with_dir do
64
+ return Pathname.new(%x/git rev-parse --show-toplevel/.chomp)
65
+ end
66
+ end
67
+ #relative path from toplevel to @dir
68
+ def prefix
69
+ with_dir do
70
+ return Pathname.new(%x/git rev-parse --show-prefix/.chomp)
71
+ end
72
+ end
73
+ #return the relative path from @dir to the toplevel
74
+ def relative_toplevel
75
+ with_dir do
76
+ return Pathname.new(%x/git rev-parse --show-cdup/.chomp)
77
+ end
78
+ end
79
+ #get path to .git directory (can be relative or absolute)
80
+ def gitdir
81
+ with_dir do
82
+ return Pathname.new(%x/git rev-parse --git-dir/.chomp)
83
+ end
84
+ end
85
+
86
+ def with_toplevel(&b)
87
+ with_dir do
88
+ dir=relative_toplevel
89
+ if !dir.to_s.empty?
90
+ Dir.chdir(dir,&b)
91
+ else
92
+ warn "No toplevel found, executing inside dir #{@dir}"
93
+ with_dir(&b)
94
+ end
95
+ end
96
+ end
97
+
98
+ #return a list of submodules
99
+ def submodules
100
+ with_dir do
101
+ return %x/git submodule status/.each_line.map { |l| l.split[1] }
102
+ end
103
+ end
104
+
105
+ def get_config(*args)
106
+ with_dir do
107
+ return %x/git config #{args.shelljoin}/.chomp
108
+ end
109
+ end
110
+
111
+ def current_branch(always: true)
112
+ with_dir do
113
+ branchname= %x/git symbolic-ref -q --short HEAD/.chomp!
114
+ branchname||= %x/git rev-parse --verify HEAD/.chomp! if always
115
+ return branch(branchname)
116
+ end
117
+ end
118
+
119
+ def head
120
+ return branch('HEAD')
121
+ end
122
+
123
+ #return all branches that have an upstream
124
+ #if branches=:all look through all branches
125
+ def all_upstream_branches(branches)
126
+ #TODO
127
+ upstreams=%x!git for-each-ref --format='%(upstream:short)' refs/heads/branch/!
128
+ end
129
+
130
+ def get_topic_branches(*branches, complete: :local)
131
+ if branches.length >= 2
132
+ return branch(branches[0]), branch(branches[1])
133
+ elsif branches.length == 1
134
+ b=branch(branches[0])
135
+ if complete == :local
136
+ return current_branch, b
137
+ elsif complete == :remote
138
+ return b, b.upstream
139
+ else
140
+ fail "complete keyword should be :local or :remote"
141
+ end
142
+ else
143
+ c=current_branch
144
+ return c, c.upstream
145
+ end
146
+ end
147
+
148
+ def branch(branch="HEAD")
149
+ GitBranch.new(branch, dir: @self)
150
+ end
151
+
152
+ def branch_infos(*branches, local: false, remote: false, tags: false)
153
+ query=branches.map {|b| name_branch(b, method: 'full_name')}
154
+ query << 'refs/heads' if local
155
+ query << 'refs/remotes' if remote
156
+ query << 'refs/tags' if tags
157
+ r={}
158
+ format=%w(refname refname:short objecttype objectsize objectname upstream upstream:short upstream:track upstream:remotename upstream:remoteref push push:short push:remotename push:remoteref HEAD symref)
159
+ out=SH::Run.run_simple("git for-each-ref --format '#{format.map {|f| "%(#{f})"}.join(',')}, ' #{query.shelljoin}", chomp: :lines)
160
+ out.each do |l|
161
+ infos=l.split(',')
162
+ full_name=infos[0]
163
+ r[full_name]=Hash[format.zip(infos)]
164
+ type=if full_name.start_with?("refs/heads/")
165
+ :local
166
+ elsif full_name.start_with?("refs/remotes/")
167
+ :remote
168
+ elsif full_name.start_with?("refs/tags/")
169
+ :tags
170
+ end
171
+ name = case type
172
+ when :local
173
+ full_name.delete_prefix("refs/heads/")
174
+ when :remote
175
+ full_name.delete_prefix("refs/remotes/")
176
+ when :tags
177
+ full_name.delete_prefix("refs/tags/")
178
+ end
179
+ r[full_name][:type]=type
180
+ r[full_name][:name]=name
181
+ end
182
+ r
183
+ end
184
+
185
+ def name_branch(branch,*args)
186
+ self.branch(branch).name(*args)
187
+ end
188
+ end
189
+
190
+ extend self
191
+ add_instance_methods = lambda do |klass|
192
+ klass.instance_methods(false).each do |m|
193
+ define_method(m) do |*args,&b|
194
+ GitDir.new.public_send(m,*args,&b)
195
+ end
196
+ end
197
+ end
198
+ add_instance_methods.call(GitDir)
199
+ add_instance_methods.call(GitStats)
200
+ add_instance_methods.call(GitExtraInfos)
201
+
202
+ class GitBranch
203
+ attr_accessor :gitdir
204
+ attr_accessor :branch
205
+ attr_writer :infos
206
+
207
+ def initialize(branch="HEAD", dir: ".")
208
+ @gitdir=dir.is_a?(GitDir) ? dir : GitDir.new(dir)
209
+ @branch=branch
210
+ end
211
+
212
+ def new_branch(name)
213
+ self.class.new(name, @gitdir)
214
+ end
215
+
216
+ def to_s
217
+ @branch.to_s
218
+ end
219
+
220
+ def nil?
221
+ @branch.nil?
222
+ end
223
+
224
+ def shellescape
225
+ @branch.shellescape
226
+ end
227
+
228
+ def infos
229
+ return @infos if @infos
230
+ infos=branch_infos
231
+ type=infos[:type]
232
+ if type == :local
233
+ rebase=gitdir.get_config("branch.#{name}.rebase")
234
+ rebase = false if rebase.empty?
235
+ rebase = true if rebase == "true"
236
+ infos[:rebase]=rebase
237
+ end
238
+ @infos=infos
239
+ end
240
+
241
+ def name(method: "name", always: true)
242
+ @gitdir.with_dir do
243
+ case method
244
+ when "sha1"
245
+ describe=%x"git rev-parse --short #{@branch.shellescape}".chomp!
246
+ when "describe"
247
+ describe=%x"git describe #{@branch.shellescape}".chomp!
248
+ when "contains"
249
+ describe=%x"git describe --contains #{@branch.shellescape}".chomp!
250
+ when "match"
251
+ describe=%x"git describe --tags --exact-match #{@branch.shellescape}".chomp!
252
+ when "topic"
253
+ describe=%x"git describe --all #{@branch.shellescape}".chomp!
254
+ when "branch"
255
+ describe=%x"git describe --contains --all #{@branch.shellescape}".chomp!
256
+ when "topic-fb" #try --all, then --contains all
257
+ describe=%x"git describe --all #{@branch.shellescape}".chomp!
258
+ describe=%x"git describe --contains --all #{@branch.shellescape}".chomp! if describe.nil? or describe.empty?
259
+ when "branch-fb" #try --contains all, then --all
260
+ describe=%x"git describe --contains --all #{@branch.shellescape}".chomp!
261
+ describe=%x"git describe --all #{@branch.shellescape}".chomp! if describe.nil? or describe.empty?
262
+ when "magic"
263
+ describe1=%x"git describe --contains --all #{@branch.shellescape}".chomp!
264
+ describe2=%x"git describe --all #{@branch.shellescape}".chomp!
265
+ describe= describe1.length < describe2.length ? describe1 : describe2
266
+ describe=describe1 if describe2.empty?
267
+ describe=describe2 if describe1.empty?
268
+ when "name"
269
+ describe=%x"git rev-parse --abbrev-ref --symbolic-full-name #{@branch.shellescape}".chomp!
270
+ when "full_name"
271
+ describe=%x"git rev-parse --symbolic-full-name #{@branch.shellescape}".chomp!
272
+ when "symbolic"
273
+ describe=%x"git rev-parse --symbolic #{@branch.shellescape}".chomp!
274
+ else
275
+ describe=%x/#{method}/.chomp! unless method.nil? or method.empty?
276
+ end
277
+ if (describe.nil? or describe.empty?) and always
278
+ describe=%x/git rev-parse --short #{@branch.shellescape}/.chomp!
279
+ end
280
+ return describe
281
+ end
282
+ end
283
+
284
+ def rebase?
285
+ @gitdir.with_dir do
286
+ rb=%x/git config --bool branch.#{@branch.shellescape}.rebase/.chomp!
287
+ rb||=%x/git config --bool pull.rebase/.chomp!
288
+ return rb=="true"
289
+ end
290
+ end
291
+
292
+ def remote
293
+ @gitdir.with_dir do
294
+ rm=%x/git config --get branch.#{@branch.shellescape}.remote/.chomp!
295
+ rm||="origin"
296
+ return rm
297
+ end
298
+ end
299
+
300
+ def push_remote
301
+ @gitdir.with_dir do
302
+ rm= %x/git config --get branch.#{@branch.shellescape}.pushRemote/.chomp! ||
303
+ %x/git config --get remote.pushDefault/.chomp! ||
304
+ remote
305
+ return rm
306
+ end
307
+ end
308
+
309
+ def upstream
310
+ @gitdir.with_dir do
311
+ up=%x/git rev-parse --abbrev-ref #{@branch.shellescape}@{u}/.chomp!
312
+ return new_branch(up)
313
+ end
314
+ end
315
+
316
+ def push
317
+ @gitdir.with_dir do
318
+ pu=%x/git rev-parse --abbrev-ref #{@branch.shellescape}@{push}/.chomp!
319
+ return new_branch(pu)
320
+ end
321
+ end
322
+
323
+ def hash
324
+ @hash||=`git rev-parse #{@branch.shellescape}`.chomp!
325
+ end
326
+
327
+ def ==(other)
328
+ @branch == other.branch && @gitdir=other.gitdir
329
+ end
330
+
331
+ #return upstream + push if push !=upstream
332
+ def related
333
+ up=upstream
334
+ pu=push
335
+ pu=new_branch(nil) if up==pu
336
+ return up, pu
337
+ end
338
+
339
+ def branch_infos
340
+ @gitdir.branch_infos(@branch).values.first
341
+ end
342
+ end
343
+
344
+ end
@@ -0,0 +1,359 @@
1
+ require 'shellwords'
2
+ require 'dr/sh'
3
+ require 'dr/base/encoding'
4
+ # require 'git_helpers' #if we are required directly
5
+
6
+ module GitHelpers
7
+ DefaultLogOptions=["-M", "-C", "--no-color"].shelljoin
8
+
9
+ module GitStats
10
+ #Note: stats-authors give the same result, should be faster, and handle mailcap
11
+ #inspired by git-mainline//git-rank-contributors
12
+ def stats_diff(logopts=nil)
13
+ lines = {}
14
+
15
+ with_dir do
16
+ author = nil
17
+ state = :pre_author
18
+ DR::Encoding.fix_utf8(`git log #{DefaultLogOptions} -p #{logopts}`).each_line do |l|
19
+ case
20
+ when (state == :pre_author || state == :post_author) && m=l[/Author: (.*)$/,1]
21
+ #TODO: using directly author=l[]... seems to only affect a block scoped author variable
22
+ author=m
23
+ state = :post_author
24
+ lines[author] ||= {added: 0, deleted: 0, all: 0}
25
+ when state == :post_author && l =~ /^\+\+\+\s/
26
+ state = :in_diff
27
+ when state == :in_diff && l =~ /^[\+\-]/
28
+ unless l=~ /^(\+\+\+|\-\-\-)\s/
29
+ lines[author][:all] += 1
30
+ lines[author][:added] += 1 if l[0]=="+"
31
+ lines[author][:deleted] += 1 if l[0]=="-"
32
+ end
33
+ when state == :in_diff && l =~ /^commit /
34
+ state = :pre_author
35
+ end
36
+ end
37
+ end
38
+ lines
39
+ end
40
+
41
+ def output_stats_diff(logopts=nil)
42
+ lines=stats_diff(logopts)
43
+ lines.sort_by { |a, c| -c[:all] }.each do |a, c|
44
+ puts "#{a}: #{c[:all]} lines of diff (+#{c[:added]}/-#{c[:deleted]})"
45
+ end
46
+ end
47
+
48
+ # inspired by visionmedia//git-line-summary
49
+ def stats_lines(file)
50
+ #p file
51
+ with_dir do
52
+ out,_suc=SH.run_simple("git", "blame", "--line-porcelain", file, quiet: true)
53
+ end
54
+ r={}
55
+ begin
56
+ out.each_line do |l|
57
+ l.match(/^author (.*)/) do |m|
58
+ r[m[1]]||=0
59
+ r[m[1]]+=1
60
+ end
61
+ end
62
+ rescue => e
63
+ warn "Warning: #{e} on #{file}"
64
+ end
65
+ r
66
+ end
67
+
68
+ def stats_lines_all
69
+ r={}
70
+ all_files.select {|f| SH::Pathname.new(f).text? rescue false}.each do |f|
71
+ stats_lines(f).each do |k,v|
72
+ r[k]||=0
73
+ r[k]+=v
74
+ end
75
+ end
76
+ r
77
+ end
78
+
79
+ def output_stats_lines
80
+ stats=stats_lines_all
81
+ total=stats.values.sum
82
+ stats.sort_by{|k,v| -v}.each do |k,v|
83
+ puts "- #{k}: #{v} (#{"%2.1f%%" % (100*v/total.to_f)})"
84
+ end
85
+ puts "Total lines: #{total}"
86
+ end
87
+
88
+ #Inspired by https://github.com/esc/git-stats/blob/master/git-stats.sh
89
+ def stats_authors(logopts=nil, more: false)
90
+ require 'set'
91
+ #Exemple: --after=..., --before=...,
92
+ # -w #word diff
93
+ # -C --find-copies-harder; -M
94
+ authors={}
95
+ with_dir do
96
+ %x/git shortlog -sn #{logopts}/.each_line do |l|
97
+ commits, author=l.chomp.split(' ', 2)
98
+ authors[author]={commits: commits.to_i}
99
+ end
100
+
101
+ if more
102
+ authors.each_key do |a|
103
+ tot_a=0; tot_r=0; tot_rename=0; files=Set.new
104
+ %x/git log #{DefaultLogOptions} #{logopts} --numstat --format="%n" --author='#{a}'/.each_line do |l|
105
+ added, deleted, file=l.chomp.split(' ',3)
106
+ #puts "#{l} => #{added}, #{deleted}, #{rest}"
107
+ tot_a+=added.to_i; tot_r+=deleted.to_i
108
+ next if file.nil?
109
+ if file.include?(' => ')
110
+ tot_rename+=1
111
+ else
112
+ files.add(file) unless file.empty?
113
+ end
114
+ end
115
+ #rev-list should be faster, but I would need to use
116
+ # `git rev-parse --revs-only --default HEAD #{logopts.shelljoin}`
117
+ # to be sure we default to HEAD, and
118
+ # `git rev-parse --flags #{logopts.shelljoin}` to get the log flags...
119
+ #tot_merges=%x/git rev-list #{logopts} --merges --author='#{a}'/.each_line.count
120
+ tot_merges=%x/git log --pretty=oneline #{logopts} --merges --author='#{a}'/.each_line.count
121
+ authors[a].merge!({added: tot_a, deleted: tot_r, files: files.size, renames: tot_rename, merges: tot_merges})
122
+ end
123
+ end
124
+ end
125
+ authors
126
+ end
127
+
128
+ def output_stats_authors(logopts=nil)
129
+ authors=stats_authors(logopts, more: true)
130
+ authors.each do |a,v|
131
+ puts "- #{a}: #{v[:commits]} commits (+#{v[:added]}/-#{v[:deleted]}), #{v[:files]} files modified, #{v[:renames]} renames, #{v[:merges]} merges"
132
+ end
133
+ end
134
+
135
+ #inspired by visionmedia//git-infos
136
+ def infos
137
+ with_dir do
138
+ puts "## Remote URLs:"
139
+ puts
140
+ system("git --no-pager remote -v")
141
+ puts
142
+
143
+ puts "## Remote Branches:"
144
+ puts
145
+ system("git --no-pager branch -r")
146
+ puts
147
+
148
+ puts "## Local Branches:"
149
+ puts
150
+ system("git --no-pager branch")
151
+ puts
152
+
153
+ puts "## Most Recent Commit:"
154
+ puts
155
+ system("git --no-pager log --max-count=1 --pretty=short")
156
+ puts
157
+ end
158
+ end
159
+
160
+ #inspired by visionmedia//git-summary
161
+ def summary(logopts=nil)
162
+ with_dir do
163
+ project=Pathname.new(%x/git rev-parse --show-toplevel/).basename
164
+ authors=stats_authors(logopts)
165
+ commits=authors.map {|a,v| v[:commits]}.sum
166
+ file_count=%x/git ls-files/.each_line.count
167
+ active_days=%x/git log --date=short --pretty='format: %ad' #{logopts}/.each_line.uniq.count
168
+ #This only give the rep age of the current branch; and is not
169
+ #efficient since we generate the first log
170
+ #A better way would be to get all the roots commits via
171
+ # git rev-list --max-parents=0 HEAD
172
+ #and then look at their ages
173
+ repository_age=%x/git log --reverse --pretty=oneline --format="%ar" #{logopts}/.each_line.first.sub!('ago','')
174
+ #total= %x/git rev-list #{logopts}/.each_line.count
175
+ total=%x/git rev-list --count #{logopts.empty? ? "HEAD" : logopts.shelljoin}/.to_i
176
+
177
+ puts " project : #{project}"
178
+ puts " repo age : #{repository_age}"
179
+ puts " active : #{active_days} days"
180
+ puts " commits : #{commits}"
181
+ puts " files : #{file_count}"
182
+ puts " authors : #{authors.keys.join(", ")} (Total: #{total})"
183
+ authors.each do |a,v|
184
+ puts " - #{a}: #{v[:commits]} (#{"%2.1f" % (100*v[:commits]/commits.to_f)}%)"
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ # various helpers
191
+ module GitExtraInfos
192
+ # Inspired by http://chneukirchen.org/dotfiles/bin/git-attic
193
+ def removed_files(logopts=nil)
194
+ removed={}
195
+ with_dir do
196
+ commit=nil; date=nil
197
+ %x/git log #{DefaultLogOptions} --raw --date=short --format="%h %cd" #{logopts}/.each_line do |l|
198
+ l.chomp!
199
+ case l
200
+ when /^[0-9a-f]/
201
+ commit, date=l.split(' ',2)
202
+ when /^:/
203
+ _old_mode, _new_mode, _old_hash, _new_hash, state, filename=l.split(' ',6)
204
+ #keep earliest removal
205
+ removed[filename]||={date: date, commit: commit} if state=="D"
206
+ end
207
+ end
208
+ end
209
+ removed
210
+ end
211
+ def output_removed_files(logopts=nil)
212
+ r=removed_files(logopts)
213
+ r.each do |file, data|
214
+ puts "#{data[:date]} #{data[:commit]}^:#{file}"
215
+ end
216
+ end
217
+
218
+ #Inspired by https://gist.github.com/7590246.git
219
+ def commit_children(*commits)
220
+ r={}
221
+ with_dir do
222
+ commits.each do |commit|
223
+ commit_id=%x/git rev-parse "#{commit}^0"/.chomp #dereference tags
224
+ %x/git rev-list --all --not #{commit_id}^@ --children/.each_line do |l|
225
+ if l=~/^#{commit_id}/
226
+ _commit, *children=l.chomp.split
227
+ described=children.map {|c| %x/git describe --always #{c}/.chomp}
228
+ r[commit]||=[]
229
+ r[commit]+=described
230
+ end
231
+ end
232
+ end
233
+ end
234
+ r
235
+ end
236
+ def output_commit_children(*commits)
237
+ commit_children(*commits).each do |commit, children|
238
+ puts "#{commit}: #{children.join(", ")}"
239
+ end
240
+ end
241
+
242
+ #number of commits modifying each file (look in the logs)
243
+ #Inspired by the script git-churn, written by Corey Haines # Scriptified by Gary Bernhardt
244
+ def log_commits_by_files(logopts=nil)
245
+ r={}
246
+ with_dir do
247
+ files=%x/git log #{DefaultLogOptions} --name-only --format="" #{logopts}/.each_line.map {|l| l.chomp!}
248
+ uniq=files.uniq
249
+ uniq.each do |file|
250
+ r[file]=files.count(file)
251
+ end
252
+ end
253
+ r
254
+ end
255
+ def output_log_commits_by_files(logopts=nil)
256
+ log_commits_by_files(logopts).sort {|f1, f2| -f1[1] <=> -f2[1]}.each do |file, count|
257
+ puts "- #{file}: #{count}"
258
+ end
259
+ end
260
+
261
+ #Inspired by the script git-effort from visionmedia
262
+ def commits_by_files(*files)
263
+ r={}
264
+ files=all_files if files.empty?
265
+ with_dir do
266
+ files.each do |file|
267
+ dates=%x/git log #{DefaultLogOptions} --pretty='format: %ad' --date=short -- "#{file}"/.each_line.map {|l| l.chomp}
268
+ r[file]={commits: dates.length, active: dates.uniq.length}
269
+ end
270
+ end
271
+ r
272
+ end
273
+ def output_commits_by_files(*files)
274
+ commits_by_files(*files).each do |file, data|
275
+ puts "- #{file}: #{data[:commits]} (active: #{data[:active]} days)"
276
+ end
277
+ end
278
+
279
+ #git config --list
280
+ #inspired by visionmedia//git-alias
281
+ def aliases
282
+ with_dir do
283
+ %x/git config --get-regexp 'alias.*'/.each_line.map do |l|
284
+ puts l.sub(/^alias\./,"").sub(/ /," = ")
285
+ end
286
+ end
287
+ end
288
+
289
+ #inspired by git-trail from https://github.com/cypher/dotfiles
290
+ #merges: key=branch point hash, values=tips names
291
+ def trails(commit, remotes: true, tags: true)
292
+ merges={}
293
+ with_dir do
294
+ %x/git for-each-ref/.each_line do |l|
295
+ hash, type, name=l.split
296
+ next if type=="tags" and !tags
297
+ next if type=="commit" && !name.start_with?("refs/heads/") and !remotes
298
+ mb=`git merge-base #{commit.shellescape} #{hash}`.chomp
299
+ mb=:disjoint if mb.empty?
300
+ merges[mb]||=[]
301
+ merges[mb] << name
302
+ end
303
+ end
304
+ merges
305
+ end
306
+
307
+ def output_all_trails(*args, **opts)
308
+ args.each do |commit|
309
+ trails(commit, **opts).each do |mb, tips|
310
+ next if mb==:disjoint
311
+ with_dir do
312
+ l=%x/git -c color.ui=always log -n1 --date=short --format="%C(auto,green)%cd %C(auto)%h" #{mb}/
313
+ date, short_hash=l.split
314
+ nr=tips.map do |tip|
315
+ `git name-rev --name-only --refs=#{tip.shellescape} #{mb}`.chomp
316
+ end
317
+ puts "#{date}: #{short_hash} – #{nr.join(', ')}"
318
+ end
319
+ end
320
+ end
321
+ end
322
+
323
+ #only output trails present in the log options passed
324
+ def output_trails(*args, **opts)
325
+ with_dir do
326
+ commit=`git rev-parse --revs-only --default HEAD #{args.shelljoin}`.chomp
327
+ merges=trails(commit, **opts)
328
+ %x/git -c color.ui=always log --date=short --format="%C(auto,green)%cd %C(auto)%h%C(reset) %H" #{args.shelljoin}/.each_line do |l|
329
+ date, short_hash, hash=l.split
330
+ if merges.key?(hash)
331
+ nr=merges[hash].map do |tip|
332
+ `git name-rev --name-only --refs=#{tip.shellescape} #{hash}`.chomp
333
+ end
334
+ puts "#{date}: #{short_hash} – #{nr.join(', ')}"
335
+ end
336
+ end
337
+ end
338
+ end
339
+
340
+ #inspired by git-neck from https://github.com/cypher/dotfiles
341
+ def neck(*args, **opts)
342
+ with_dir do
343
+ commit=`git rev-parse --revs-only --default HEAD #{args.shelljoin}`.chomp
344
+ log_opts=`git rev-parse --flags --no-revs #{args.shelljoin}`.chomp
345
+ hash=`git rev-parse #{commit.shellescape}`.chomp
346
+ merges=trails(commit, **opts)
347
+ merges.delete(hash) #todo: only delete if we are the only tip
348
+ merges.delete(:disjoint)
349
+ system("git --no-pager -c color.ui=always log --pretty=summary #{log_opts} #{merges.keys.map {|mb| "^#{mb}"}.join(" ")} #{commit}")
350
+ puts
351
+ end
352
+ end
353
+ end
354
+
355
+ class GitDir
356
+ include GitStats
357
+ include GitExtraInfos
358
+ end
359
+ end