git_helpers 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.travis.yml +10 -0
- data/.yardopts +6 -0
- data/ChangeLog.md +4 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +20 -0
- data/README.md +55 -0
- data/Rakefile +29 -0
- data/bin/diff-fancy.rb +699 -0
- data/bin/gitstatus.rb +349 -0
- data/fixtures/git-diff.diff +254 -0
- data/gemspec.yml +18 -0
- data/git_helpers.gemspec +71 -0
- data/lib/git_helpers.rb +344 -0
- data/lib/git_helpers/extra_helpers.rb +359 -0
- data/lib/git_helpers/version.rb +4 -0
- data/test/helper.rb +13 -0
- data/test/test_git_helpers.rb +12 -0
- metadata +178 -0
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"
|
data/git_helpers.gemspec
ADDED
@@ -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
|
data/lib/git_helpers.rb
ADDED
@@ -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
|