repokeeper 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2014 Anatoliy Plastinin
2
+
3
+ This program is free software: you can redistribute it and/or modify
4
+ it under the terms of the GNU General Public License as published by
5
+ the Free Software Foundation, either version 3 of the License, or
6
+ (at your option) any later version.
7
+
8
+ This program is distributed in the hope that it will be useful,
9
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ GNU General Public License for more details.
12
+
13
+ You should have received a copy of the GNU General Public License
14
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
data/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # Repokeeper
2
+
3
+ ## What's all this about?
4
+
5
+ Repokeeper is a tool for git repositories analysis that highlights common
6
+ flaws/bad practices in a workflow.
7
+
8
+ ## Flaws in workflow? What does it mean?
9
+
10
+ Flaws commonly seen in repository management.
11
+
12
+ ## Why do I need a tool for this?
13
+
14
+ There are many tools for static code analysis like rubocop, flay, flog,
15
+ codeclimate and etc.
16
+ Why not analyze repositories?
17
+ Unlike code, which can be refactored to fix issues, it's not possible to
18
+ refactor repo history (of course, you can rewrite git history, but rewriting
19
+ commits merged into master branch is a bad idea).
20
+ By using Repokeeper, you can explore what was wrong in project's history, and
21
+ then adjust your process accordingly.
22
+
23
+
24
+ Problems with project management usually leave traces in repository history and
25
+ structure.
26
+ For instance, a rush in delivery of features to production can lead to debugging
27
+ and fixing in production, which in turn, leads to many `fix`-like commits.
28
+
29
+ ## Story behind this project
30
+
31
+ Once upon a time... while reviewing a pull request, the author found two commits
32
+ saying:
33
+
34
+ Implementing uploader
35
+ Implementing uploader
36
+
37
+ His immediate thoughts were: "WTF??? What is it? Did the first attempt fail to
38
+ work?"
39
+ But then, he decided to write a tool to analyze other repos to see if this was a
40
+ unique case, or if he had stumbled upon a more common problem.
41
+
42
+ ## What can this tool tell me about my repo?
43
+
44
+ Tool detects following problems:
45
+
46
+ * **Short commit message.**
47
+ The most important thing one can do using any VCS is to write meaningful commit
48
+ message, because it will help others to better understand your changes.
49
+ If you find commit messages like: `fix` or `!!!`, that means someone doesn't do
50
+ a good job in revealing the intention behind these commits.
51
+
52
+ * **Duplicate commit message.**
53
+ A sequence of two or more sequential commits with the same message might
54
+ indicate that someone didn't find time to squash those commits or that someone
55
+ pushed a fix to master (or even deployed it to production) and it didn't work.
56
+
57
+ * **Merge commit.**
58
+ This is a commit with 2 parents. It's hard to follow project history if it
59
+ includes tons of merges.
60
+ This analysis might be useful in case you follow rebase and fast-forward only
61
+ merges workflow. Read more about rebase
62
+ [here](http://randyfay.com/content/rebase-workflow-git)
63
+ or [here](http://robots.thoughtbot.com/rebase-like-a-boss).
64
+
65
+ * **Issues with branches**.
66
+ Branches analyzers are experimental, and there are will be changes in future.
67
+ At this moment, the tool reports a warning if you have too many local or remote
68
+ branches.
69
+ Too many branches can indicate that you aren’t maintaining your repository well
70
+ (i.e., you aren’t deleting local and remote feature branches after merging it).
71
+
72
+ ## OK, I want to try it
73
+
74
+ Install it from ruby gems
75
+
76
+ $ gem install repokeeper
77
+
78
+ After that
79
+
80
+ * Goto some repo's directory
81
+ * Run `repokeeper`
82
+ * Have fun
83
+
84
+ Also you can pass a path to repository, instead switching to the directory with
85
+ the repository: `repokeeper super_cool_project`
86
+
87
+ You can override default analyzers settings providing a config file with `-c`
88
+ option, e.g. `repokeeper -c .repokeeper.yml`
89
+
90
+ See `config/default.yml` for configuration file example.
91
+
92
+ **NOTE:** `repokeeper` without `-c` argument doesn't load `.repokeeper.yml`
93
+ from current directory even if it exists.
94
+
95
+ ## I have a big project, and use non-fastforward merges all the time
96
+
97
+ If you are annoyed by tons of merge commits warnings, you can disable it,
98
+ by `enabled: false` option in config file.
99
+
100
+ ## More fun with custom formatters
101
+
102
+ You can provide custom formatter class to change output the way you want,
103
+ or calculate statistics.
104
+
105
+ To do this you should require ruby file with formatter class using `--require`
106
+ option and provide formatter class name using `--formatter` option.
107
+
108
+ ### Complex example on how to use custom formatters
109
+
110
+ Let's create a list of most common short commit messages in a repository.
111
+
112
+ ```{ruby}
113
+ # messages_score_formatter.rb
114
+ class MessagesScoreFormatter
115
+ def initialize(out_stream = $stdout)
116
+ @out_stream = out_stream
117
+ end
118
+
119
+ def started
120
+ @counts = Hash.new(0)
121
+ end
122
+
123
+ def commits_analyzer_results(analyzer_name, offenses)
124
+ Array(offenses).each do |offense|
125
+ messsage = offense.commit.message.strip.downcase
126
+ @counts[messsage] += 1
127
+ end
128
+ end
129
+
130
+ def branches_analyzer_results(analyzer_name, offenses)
131
+ end
132
+
133
+ def finished
134
+ @counts.sort_by{ |_, v| -v }.each do |k, v|
135
+ @out_stream.puts "#{k}: #{v}"
136
+ end
137
+ end
138
+ end
139
+ ```
140
+
141
+ We are interested to use only short commit messages analyzer, so other analyzers
142
+ can be disabled in configuration file:
143
+
144
+ ```{yaml}
145
+ # .repokeeper.yml
146
+ local_branches_count:
147
+ enabled: false
148
+
149
+ remote_branches_count:
150
+ enabled: false
151
+
152
+ merge_commits:
153
+ enabled: false
154
+
155
+ short_commit_message:
156
+ message_min_length: 10
157
+
158
+ similar_commits:
159
+ enabled: false
160
+ ```
161
+
162
+ And everything is ready to run:
163
+
164
+ repokeeper --require ./messages_score_formatter.rb \
165
+ --formatter MessagesScoreFormatter \
166
+ -c .repokeeper.yml super_cool_project
167
+
168
+ ## Contributing
169
+
170
+ You know what to do.
171
+ But don't forget to run `repokeeper` against your new commits ;)
172
+
173
+
174
+ ## Contact
175
+
176
+ Anatoliy Plastinin
177
+
178
+ - http://github.com/antlypls
179
+ - http://twitter.com/antlypls
180
+ - hello@antlypls.com
181
+
182
+ ## License
183
+
184
+ See `LICENSE`
data/bin/repokeeper ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << File.expand_path('../../lib', __FILE__)
4
+
5
+ require 'repokeeper'
6
+
7
+ Repokeeper::CLI.go!
@@ -0,0 +1,13 @@
1
+ local_branches_count:
2
+ max_local_branches: 5
3
+
4
+ remote_branches_count:
5
+ max_remote_branches: 5
6
+
7
+ merge_commits:
8
+
9
+ short_commit_message:
10
+ message_min_length: 10
11
+
12
+ similar_commits:
13
+ min_edit_distance: 4
@@ -0,0 +1,26 @@
1
+ module Repokeeper::Analyzers
2
+ # Base class for sequential commits analyzers
3
+ class Analyzer
4
+ @all = AnalyzersSet.new
5
+
6
+ def self.all
7
+ @all.clone
8
+ end
9
+
10
+ def self.inherited(subclass)
11
+ @all << subclass
12
+ end
13
+
14
+ def initialize(config)
15
+ @config = config || {}
16
+ end
17
+
18
+ def name
19
+ self.class.name.split('::').last
20
+ end
21
+
22
+ def enabled?
23
+ @config.fetch('enabled') { true }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ module Repokeeper::Analyzers
2
+ class AnalyzersSet < Array
3
+ def commits_analyzers
4
+ select { |a| a.type == :commit }
5
+ end
6
+
7
+ def branch_analyzers
8
+ select { |a| a.type == :branch }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module Repokeeper::Analyzers
2
+ class LocalBranchesCount < Analyzer
3
+ include BranchesAnalyzer
4
+ include BranchesCount
5
+
6
+ def max_branches
7
+ @config['max_local_branches']
8
+ end
9
+
10
+ private
11
+
12
+ def get_branches(repo)
13
+ repo.local_branches
14
+ end
15
+
16
+ def message
17
+ 'too many local branches'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ module Repokeeper::Analyzers
2
+ class RemoteBranchesCount < Analyzer
3
+ include BranchesAnalyzer
4
+ include BranchesCount
5
+
6
+ def max_branches
7
+ @config['max_remote_branches']
8
+ end
9
+
10
+ private
11
+
12
+ def get_branches(repo)
13
+ repo.remote_branches
14
+ end
15
+
16
+ def message
17
+ 'too many remote branches'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ module Repokeeper::Analyzers
2
+ # Analyzes merge commits
3
+ # commits level analyzer
4
+ class MergeCommits < Analyzer
5
+ include CommitsAnalyzer
6
+
7
+ def process_commit(commit)
8
+ create_offense(commit, 'merge commit') if commit.parents.count > 1
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ module Repokeeper::Analyzers
2
+ # Analyzes short commit messages
3
+ # sign of undescriptive message
4
+ # commits level analyzer
5
+ class ShortCommitMessage < Analyzer
6
+ include CommitsAnalyzer
7
+
8
+ def message_min_length
9
+ @config['message_min_length']
10
+ end
11
+
12
+ def process_commit(commit)
13
+ create_offense_message(commit) if error?(commit)
14
+ end
15
+
16
+ private
17
+
18
+ def error?(commit)
19
+ commit.message.length < message_min_length
20
+ end
21
+
22
+ def create_offense_message(commit)
23
+ create_offense(commit, "Short commit message: #{commit.message}")
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Repokeeper::Analyzers
2
+ # Checks for similar commits messages in commit ant its parents
3
+ # commits level analyzer
4
+ class SimilarCommits < Analyzer
5
+ include CommitsAnalyzer
6
+
7
+ def min_edit_distance
8
+ @config['min_edit_distance']
9
+ end
10
+
11
+ def process_commit(commit)
12
+ commit.parents.map do |parent|
13
+ compare_commits(commit, parent)
14
+ end.compact
15
+ end
16
+
17
+ private
18
+
19
+ def compare_commits(newer, older)
20
+ new_message = clean_commit_message(newer)
21
+ old_message = clean_commit_message(older)
22
+
23
+ distance = Repokeeper::Utils.edit_distance(new_message, old_message)
24
+ error = error_message_by_distance(distance, new_message, old_message)
25
+ create_offense_for_error(error, newer, older)
26
+ end
27
+
28
+ def error_message_by_distance(distance, new_message, old_message)
29
+ if distance == 0
30
+ "Same commit message: '#{new_message}'"
31
+ elsif distance < min_edit_distance
32
+ "Similar commit messages: '#{new_message}' and '#{old_message}'"
33
+ end
34
+ end
35
+
36
+ def create_offense_for_error(error, newer, older)
37
+ create_offense(newer, "#{error}. See #{older.oid}") if error
38
+ end
39
+
40
+ def clean_commit_message(commit)
41
+ commit.message.gsub(/[\t\n]/, ' ').strip
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ module Repokeeper::Analyzers
2
+ module BranchesAnalyzer
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ def type
9
+ :branch
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ module Repokeeper::Analyzers
2
+ module BranchesCount
3
+ def analyze(repo)
4
+ branches = get_branches(repo)
5
+ report(branches) if error?(branches)
6
+ end
7
+
8
+ private
9
+
10
+ def error?(branches)
11
+ branches.count > max_branches
12
+ end
13
+
14
+ def report(branches)
15
+ create_offense(branches)
16
+ end
17
+
18
+ def create_offense(branches)
19
+ Repokeeper::Offenses::BranchesOffense.new(branches, message, name)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ module Repokeeper::Analyzers
2
+ module CommitsAnalyzer
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ def type
9
+ :commit
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def create_offense(commit, message)
16
+ Repokeeper::Offenses::CommitOffense.new(commit, message, name)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ require_relative 'analyzers/mixins/branches_analyzer'
2
+ require_relative 'analyzers/mixins/branches_count'
3
+ require_relative 'analyzers/mixins/commits_analyzer'
4
+ require_relative 'analyzers/analyzers_set'
5
+ require_relative 'analyzers/analyzer'
6
+ require_relative 'analyzers/commits/merge_commits'
7
+ require_relative 'analyzers/commits/short_commit_message'
8
+ require_relative 'analyzers/commits/similar_commits'
9
+ require_relative 'analyzers/branches/local_branches_count'
10
+ require_relative 'analyzers/branches/remote_branches_count'
@@ -0,0 +1,62 @@
1
+ require 'methadone'
2
+
3
+ module Repokeeper
4
+ class CLI
5
+ include Methadone::Main
6
+ include Methadone::CLILogging
7
+
8
+ version VERSION, compact: true
9
+ description 'Repokeeper checks your repo for flaws'
10
+
11
+ arg :path, :optional,
12
+ 'path to repo to analyze, current dir if not specified'
13
+
14
+ on '-r REV_RANGE', '--rev-range',
15
+ 'Revisions to analyze by commits analyzers'
16
+
17
+ on '-c CONFIG_FILE', '--config',
18
+ 'Configuration file'
19
+
20
+ on '--require REQUIRE_FILE',
21
+ 'File to require'
22
+
23
+ on '--formatter FORMATTER_CLASS',
24
+ 'Formatter class'
25
+
26
+ main do |path|
27
+ file_to_require = options['require']
28
+ require file_to_require if file_to_require
29
+
30
+ formatter_class = formatter_class_by_name(options['formatter'])
31
+
32
+ config_file = options['config']
33
+ repo_analyzer = create_analyzer(path, config_file, formatter_class)
34
+ range = rev_range(options['rev-range'])
35
+ repo_analyzer.analyze(range)
36
+ end
37
+
38
+ def self.formatter_class_by_name(name)
39
+ if name && !name.empty?
40
+ const_get(name)
41
+ else
42
+ SimpleTextFormatter
43
+ end
44
+ end
45
+
46
+ def self.create_analyzer(path, config_file, formatter_class)
47
+ formatter = formatter_class.new
48
+
49
+ analyzers = Analyzers::Analyzer.all
50
+
51
+ proxy = RepoProxy.new(path)
52
+ config = Config.read(config_file)
53
+ RepoAnalyzer.new(proxy, formatter, analyzers, config)
54
+ end
55
+
56
+ def self.rev_range(rev_spec)
57
+ parser = RevParser.new(rev_spec)
58
+ parser.parse
59
+ parser.range
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,43 @@
1
+ module Repokeeper
2
+ class Config
3
+ HOME_DIR = File.expand_path('../../../', __FILE__)
4
+ CONFIG_DIR = File.join(HOME_DIR, 'config')
5
+ DEFAULT_CONFIG = File.join(CONFIG_DIR, 'default.yml')
6
+
7
+ def initialize(hash)
8
+ @config = hash
9
+ end
10
+
11
+ def for(klass)
12
+ key = klass.name.split('::').last
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
14
+ @config[key]
15
+ end
16
+
17
+ def self.read(file_path = nil)
18
+ config_file = read_configuration_file(file_path)
19
+ config = merge_hashes(default_configuration, config_file)
20
+ new(config)
21
+ end
22
+
23
+ def self.default_configuration
24
+ read_configuration_file(DEFAULT_CONFIG)
25
+ end
26
+
27
+ def self.read_configuration_file(file_path)
28
+ return {} unless file_path
29
+ YAML.load_file(file_path)
30
+ end
31
+ private_class_method :read_configuration_file
32
+
33
+ def self.merge_hashes(hash1, hash2)
34
+ hash1.merge(hash2) do |_, oldval, newval|
35
+ old_hash = Hash(oldval)
36
+ new_hash = Hash(newval)
37
+
38
+ old_hash.merge(new_hash)
39
+ end
40
+ end
41
+ private_class_method :read_configuration_file
42
+ end
43
+ end
@@ -0,0 +1,8 @@
1
+ module Repokeeper
2
+ module Offenses
3
+ class BranchesOffense < Struct.new(:branches, :message, :analyzer_name)
4
+ # we want Array(offense) to return [offense]
5
+ undef_method :to_a
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Repokeeper
2
+ module Offenses
3
+ class CommitOffense < Struct.new(:commit, :message, :analyzer_name)
4
+ # we want Array(offense) to return [offense]
5
+ undef_method :to_a
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,68 @@
1
+ module Repokeeper
2
+ # provides infrastructure for running commits analyzers
3
+ class RepoAnalyzer
4
+ def initialize(repo_proxy, formatter, analyzers, config)
5
+ @analyzers = analyzers
6
+ @formatter = formatter
7
+ @repo = repo_proxy
8
+ @config = config
9
+ end
10
+
11
+ def analyze(rev_range = nil)
12
+ @rev_range = rev_range
13
+
14
+ @formatter.started
15
+ run_commits_analyzers
16
+ run_branches_analyzers
17
+ @formatter.finished
18
+ end
19
+
20
+ private
21
+
22
+ def commits_analyzers
23
+ @analyzers.commits_analyzers
24
+ end
25
+
26
+ def branch_analyzers
27
+ @analyzers.branch_analyzers
28
+ end
29
+
30
+ def enabled_analyzers(collection)
31
+ collection
32
+ .map { |analyzer_class| instantiate_analyzer(analyzer_class) }
33
+ .select { |analyzer| analyzer.enabled? }
34
+ end
35
+
36
+ def enabled_commits_analyzers
37
+ @enabled_commits_analyzers ||= enabled_analyzers(commits_analyzers)
38
+ end
39
+
40
+ def enabled_branches_analyzers
41
+ @enabled_branches_analyzers ||= enabled_analyzers(branch_analyzers)
42
+ end
43
+
44
+ def run_commits_analyzers
45
+ commits.each do |commit|
46
+ enabled_commits_analyzers.each do |analyzer|
47
+ result = analyzer.process_commit(commit)
48
+ @formatter.commits_analyzer_results(analyzer.name, result)
49
+ end
50
+ end
51
+ end
52
+
53
+ def run_branches_analyzers
54
+ enabled_branches_analyzers.each do |analyzer|
55
+ result = analyzer.analyze(@repo)
56
+ @formatter.branches_analyzer_results(analyzer.name, result)
57
+ end
58
+ end
59
+
60
+ def instantiate_analyzer(analyzer_class)
61
+ analyzer_class.new(@config.for(analyzer_class))
62
+ end
63
+
64
+ def commits
65
+ @commits ||= @repo.commits(@rev_range)
66
+ end
67
+ end
68
+ end