git_helpers 0.1.0
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.
- 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
|