todidnt 0.2.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -20,14 +20,12 @@ Then, run `todidnt` in any Git repository directory:
20
20
  TODO[1]
21
21
  ----
22
22
 
23
- - Optimizing blame step
24
23
  - Filtering by author, label
25
- - Fancy formatted reports
26
24
  - TODO stats
27
25
 
28
26
  Credit
29
27
  ----
30
28
 
31
- Paul Battley (@threedaymonk) for initial idea/implementation and witty name.
29
+ Paul Battley (@threedaymonk) for initial idea and witty name.
32
30
 
33
31
  1: Oh, the irony.
@@ -0,0 +1,48 @@
1
+ require 'tilt'
2
+ require 'erb'
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module Todidnt
7
+ class Cache
8
+ CACHE_PATH = '.todidnt/cache'
9
+
10
+ attr_reader :time, :data
11
+
12
+ def initialize(data)
13
+ @time = Time.now.to_i
14
+ @data = data
15
+ end
16
+
17
+ def self.save(key, data)
18
+ # Create the destinateion folder unless it already exists.
19
+ FileUtils.mkdir_p(CACHE_PATH)
20
+
21
+ File.open("#{CACHE_PATH}/#{key}", 'w') do |file|
22
+ file.write(Marshal.dump(Cache.new(data)))
23
+ end
24
+ end
25
+
26
+ def self.load(key)
27
+ return nil unless exists?(key)
28
+
29
+ begin
30
+ raw_cache = File.open("#{CACHE_PATH}/#{key}").read
31
+ Marshal.load(raw_cache)
32
+ rescue Exception
33
+ puts "Cache file was malformed; skipping..."
34
+ nil
35
+ end
36
+ end
37
+
38
+ def self.exists?(key)
39
+ File.exists?("#{CACHE_PATH}/#{key}")
40
+ end
41
+
42
+ def self.clear!
43
+ Dir.glob("#{CACHE_PATH}/*").each do |file|
44
+ File.delete(file)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,16 +1,18 @@
1
+ require 'subprocess'
2
+
1
3
  module Todidnt
2
4
  class GitCommand
3
5
  def initialize(command, options)
4
6
  @command = command
5
7
  @options = options
6
- end
7
8
 
8
- def output_lines
9
- run!.strip.split(/\n/)
9
+ @process = Subprocess::Process.new(command_with_options, :stdout => Subprocess::PIPE)
10
10
  end
11
11
 
12
- def run!
13
- `git #{command_with_options}`
12
+ def execute!(&blk)
13
+ @process.stdout.each_line do |line|
14
+ yield line.chomp
15
+ end
14
16
  end
15
17
 
16
18
  def command_with_options
@@ -20,7 +22,7 @@ module Todidnt
20
22
  full_command << " #{option.join(' ')}"
21
23
  end
22
24
 
23
- full_command
25
+ "git #{full_command}"
24
26
  end
25
27
  end
26
28
  end
@@ -0,0 +1,248 @@
1
+ module Todidnt
2
+ class GitHistory
3
+
4
+ attr_accessor :blames
5
+
6
+ def initialize
7
+ @history = []
8
+ @blames = {}
9
+ @unmatched_deletions = []
10
+ end
11
+
12
+ def timeline!
13
+ # TODO: It would probably be better/simpler to just Marshal the
14
+ # GitHistory object itself.
15
+ if Cache.exists?(:history)
16
+ puts "Found cached history..."
17
+
18
+ cache = Cache.load(:history)
19
+
20
+ @history = cache.data[:history]
21
+ @blames = cache.data[:blames]
22
+ @unmatched_deletions = cache.data[:unmatched_deletions]
23
+
24
+ last_commit = cache.data[:last_commit]
25
+ end
26
+
27
+ new_commit = analyze(last_commit)
28
+ if new_commit != last_commit
29
+ # If there's any new history, update the cache.
30
+ to_cache = {
31
+ last_commit: new_commit,
32
+ history: @history,
33
+ blames: @blames,
34
+ unmatched_deletions: @unmatched_deletions
35
+ }
36
+
37
+ Cache.save(:history, to_cache)
38
+ end
39
+
40
+ if @unmatched_deletions.length > 0
41
+ puts "Warning: there are some unmatched TODO deletions."
42
+ end
43
+
44
+ bucket
45
+ end
46
+
47
+ private
48
+
49
+ def analyze(last_commit=nil)
50
+ if last_commit
51
+ puts "Going through history starting at #{last_commit}..."
52
+ commit_range = ["#{last_commit}...HEAD"]
53
+ else
54
+ puts "Going through history..."
55
+ end
56
+
57
+ command = GitCommand.new(:log, [['-G', 'TODO'], commit_range, ['--format="COMMIT %an %ae %at %h"'], ['-p'], ['-U0'], ['--no-merges'], ['--reverse']].compact)
58
+
59
+ patch_additions = []
60
+ patch_deletions = []
61
+ metadata = nil
62
+ filename = nil
63
+ commit = nil
64
+ seen_commits = Set.new
65
+ count = 0
66
+
67
+ command.execute! do |line|
68
+ if (diff = /diff --git a\/(.*) b\/(.*)/.match(line))
69
+ filename = diff[1]
70
+ elsif (diff = /^\+(.*TODO.*)/.match(line))
71
+ patch_additions << diff[1] unless filename =~ TodoLine::IGNORE
72
+ elsif (diff = /^\-(.*TODO.*)/.match(line))
73
+ patch_deletions << diff[1] unless filename =~ TodoLine::IGNORE
74
+ elsif (summary = /^COMMIT (.*) (.*) (.*) (.*)/.match(line))
75
+ count += 1
76
+ $stdout.write "\r#{count} commits analyzed..."
77
+
78
+ unless commit.nil? || seen_commits.include?(commit)
79
+ flush(metadata, patch_additions, patch_deletions)
80
+ seen_commits << commit
81
+ end
82
+
83
+ patch_additions = []
84
+ patch_deletions = []
85
+
86
+ commit = summary[4]
87
+ metadata = {
88
+ name: summary[1],
89
+ time: summary[3].to_i,
90
+ }
91
+ end
92
+ end
93
+
94
+ if commit
95
+ puts
96
+ flush(metadata, patch_additions, patch_deletions)
97
+ end
98
+
99
+ return commit || last_commit # return the last commit hash we were on
100
+ end
101
+
102
+ def flush(metadata, patch_additions, patch_deletions)
103
+ name = metadata[:name]
104
+ time = metadata[:time]
105
+
106
+ # Put the additions in the blame hash so when someone removes we
107
+ # can tell who the original author was. Mrrrh, this isn't going to
108
+ # work if people add the same string (pretty common e.g. # TODO).
109
+ # We can figure this out later thoug.
110
+ patch_additions.each do |line|
111
+ @blames[line] ||= []
112
+ @blames[line] << {name: name, time: time}
113
+ end
114
+
115
+ deletions_by_author = {}
116
+ patch_deletions.each do |line|
117
+ author = @blames[line] && @blames[line].pop
118
+
119
+ if author
120
+ deletions_by_author[author[:name]] ||= 0
121
+ deletions_by_author[author[:name]] += 1
122
+ else
123
+ @unmatched_deletions << line
124
+ end
125
+ end
126
+
127
+ @history << {
128
+ :timestamp => time,
129
+ :author => name,
130
+ :additions => patch_additions.count,
131
+ :deletions => deletions_by_author[name] || 0
132
+ }
133
+
134
+ deletions_by_author.delete(name)
135
+ deletions_by_author.each do |author, deletion_count|
136
+ @history << {
137
+ :timestamp => time,
138
+ :author => author,
139
+ :additions => 0,
140
+ :deletions => deletion_count
141
+ }
142
+ end
143
+ end
144
+
145
+ def bucket
146
+ @history.sort_by! {|slice| slice[:timestamp]}
147
+ min_commit_date = Time.at(@history.first[:timestamp])
148
+ max_commit_date = Time.at(@history.last[:timestamp])
149
+
150
+ timespan = max_commit_date - min_commit_date
151
+
152
+ # Figure out what the interval should be based on the total timespan.
153
+ if timespan > 86400 * 365 * 10 # 10+ years
154
+ interval = 86400 * 365 # years
155
+ elsif timespan > 86400 * 365 * 5 # 5-10 years
156
+ interval = 86400 * (365 / 2) # 6 months
157
+ elsif timespan > 86400 * 365 # 2-5 years
158
+ interval = 86400 * (365 / 4) # 3 months
159
+ elsif timespan > 86400 * 30 * 6 # 6 months-3 year
160
+ interval = 86400 * 30 # months
161
+ elsif timespan > 86400 * 1 # 1 month - 6 months
162
+ interval = 86400 * 7
163
+ else # 0 - 2 months
164
+ interval = 86400 # days
165
+ end
166
+
167
+ original_interval_start = Time.new(min_commit_date.year, min_commit_date.month, min_commit_date.day).to_i
168
+ interval_start = original_interval_start
169
+ interval_end = interval_start + interval
170
+
171
+ puts "Finalizing timeline..."
172
+ buckets = []
173
+ current_bucket_authors = {}
174
+ bucket_total = 0
175
+
176
+ # Add the first bucket of 0
177
+ buckets << {
178
+ :timestamp => Time.at(interval_start).strftime('%D'),
179
+ :authors => {},
180
+ :total => 0
181
+ }
182
+
183
+ i = 0
184
+ # Going through the entire history of +/-'s of TODOs.
185
+ while i < @history.length
186
+ should_increment = false
187
+ slice = @history[i]
188
+ author = slice[:author]
189
+
190
+ # Does the current slice exist inside the bucket we're currently
191
+ # in? If so, add it to the author's total and go to the next slice.
192
+ if slice[:timestamp] >= interval_start && slice[:timestamp] < interval_end
193
+ current_bucket_authors[author] ||= 0
194
+ current_bucket_authors[author] += slice[:additions] - slice[:deletions]
195
+ bucket_total += slice[:additions] - slice[:deletions]
196
+ should_increment = true
197
+ end
198
+
199
+ # If we're on the last slice, or the next slice would have been
200
+ # in a new bucket, finish the current bucket.
201
+ if i == (@history.length - 1) || @history[i + 1][:timestamp] >= interval_end
202
+ buckets << {
203
+ :timestamp => ([Time.at(interval_end), max_commit_date].min).strftime('%D'),
204
+ :authors => current_bucket_authors,
205
+ :total => bucket_total
206
+ }
207
+ interval_start += interval
208
+ interval_end += interval
209
+
210
+ current_bucket_authors = current_bucket_authors.clone
211
+ end
212
+
213
+ i += 1 if should_increment
214
+ end
215
+
216
+ authors = Set.new
217
+ contains_other = false
218
+ buckets.each do |bucket|
219
+ significant_authors = {}
220
+ other_count = 0
221
+ bucket[:authors].each do |author, count|
222
+ # Only include the author if they account for more than > 3% of
223
+ # the TODOs in this bucket.
224
+ if count > bucket[:total] * 0.03
225
+ significant_authors[author] = count
226
+ authors << author
227
+ else
228
+ other_count += count
229
+ end
230
+ end
231
+
232
+ if other_count > 0
233
+ significant_authors['Other'] = other_count
234
+ contains_other = true
235
+ end
236
+
237
+ bucket[:authors] = significant_authors
238
+ end
239
+
240
+ if contains_other
241
+ authors << 'Other'
242
+ end
243
+
244
+ [buckets, authors]
245
+ end
246
+
247
+ end
248
+ end
@@ -13,7 +13,7 @@ module Todidnt
13
13
 
14
14
  def self.generate_common
15
15
  # Create the destination folder unless it already exists.
16
- Dir.mkdir(DESTINATION_PATH) unless Dir.exists?(DESTINATION_PATH)
16
+ FileUtils.mkdir_p(DESTINATION_PATH)
17
17
 
18
18
  # Copy over directories (e.g. js, css) to the destination.
19
19
  common_dirs = []
@@ -2,52 +2,38 @@ module Todidnt
2
2
  class TodoLine
3
3
  IGNORE = %r{assets/js|third_?party|node_modules|jquery|Binary|vendor}
4
4
 
5
- attr_reader :filename, :line_number, :content, :author, :timestamp
5
+ attr_reader :filename, :line_number, :raw_content
6
+ attr_accessor :author, :timestamp
6
7
 
7
8
  def self.all(expressions)
8
9
  options = [['-n']]
9
10
  expressions.each { |e| options << ['-e', e] }
10
11
 
11
- grep = GitCommand.new(:grep, options)
12
- grep.output_lines.map do |line|
12
+ lines = []
13
+
14
+ command = GitCommand.new(:grep, options)
15
+ command.execute! do |line|
13
16
  filename, line_number, content = line.split(/:/, 3)
14
17
  unless filename =~ IGNORE
15
- lines = self.new(filename, line_number.to_i, content.strip[0..100])
18
+ lines << self.new(filename, line_number.to_i, content)
16
19
  end
17
- end.compact
20
+ end
21
+
22
+ lines
18
23
  end
19
24
 
20
- def initialize(filename, line_number, content)
25
+ def initialize(filename, line_number, raw_content)
21
26
  @filename = filename
22
27
  @line_number = line_number
23
- @content = content
24
- end
25
-
26
- # TODO: This logic should probably be moved out somewhere else
27
- def populate_blame
28
- options = [
29
- ['--line-porcelain'],
30
- ['-L', "#{@line_number},#{@line_number}"],
31
- ['-w'],
32
- [@filename]
33
- ]
34
-
35
- blame = GitCommand.new(:blame, options)
36
- blame.output_lines.each do |line|
37
- if (author = /author (.*)/.match(line))
38
- @author = author[1]
39
- elsif (author_time = /author-time (.*)/.match(line))
40
- @timestamp = author_time[1].to_i
41
- end
42
- end
28
+ @raw_content = raw_content
43
29
  end
44
30
 
45
31
  def pretty_time
46
32
  Time.at(@timestamp).strftime('%F')
47
33
  end
48
34
 
49
- def pretty
50
- "#{pretty_time} (#{author}, #{filename}:#{line_number}): #{content}"
35
+ def content
36
+ raw_content.strip[0..100]
51
37
  end
52
38
 
53
39
  def to_hash
data/lib/todidnt.rb CHANGED
@@ -1,6 +1,8 @@
1
+ require 'todidnt/cache'
1
2
  require 'todidnt/git_repo'
2
3
  require 'todidnt/git_command'
3
4
  require 'todidnt/todo_line'
5
+ require 'todidnt/git_history'
4
6
  require 'todidnt/html_generator'
5
7
 
6
8
  require 'chronic'
@@ -8,229 +10,47 @@ require 'launchy'
8
10
 
9
11
  module Todidnt
10
12
  class CLI
11
- VALID_COMMANDS = %w{all overdue history}
13
+ VALID_COMMANDS = %w{generate clear}
12
14
 
13
15
  def self.run(command, options)
16
+ command ||= 'generate'
17
+
14
18
  if command && VALID_COMMANDS.include?(command)
15
19
  self.send(command, options)
16
20
  elsif command
17
21
  $stderr.puts("Sorry, `#{command}` is not a valid command.")
18
22
  exit
19
- else
20
- $stderr.puts("You must specify a command! Try `todidnt all`.")
21
- end
22
- end
23
-
24
- def self.all(options)
25
- all_lines = self.all_lines(options).sort_by(&:timestamp)
26
-
27
- puts "\nOpening results..."
28
-
29
- file_path = HTMLGenerator.generate(:all, :all_lines => all_lines)
30
- Launchy.open("file://#{file_path}")
31
- end
32
-
33
- def self.overdue(options)
34
- date = Chronic.parse(options[:date] || 'now', :context => :past)
35
- if date.nil?
36
- $stderr.puts("Invalid date passed: #{options[:date]}")
37
- exit
38
- else
39
- puts "Finding overdue TODOs (created before #{date.strftime('%F')})..."
40
- end
41
-
42
- all_lines = self.all_lines(options)
43
-
44
- puts "\nResults:"
45
- all_lines.sort_by do |line|
46
- line.timestamp
47
- end.select do |line|
48
- line.timestamp < date.to_i
49
- end.each do |line|
50
- puts line.pretty
51
23
  end
52
24
  end
53
25
 
54
- def self.all_lines(options)
26
+ def self.generate(options)
55
27
  GitRepo.new(options[:path]).run do |path|
56
- puts "Running in #{path || 'current directory'}..."
57
- lines = TodoLine.all(["TODO"])
58
- puts "Found #{lines.count} TODOs. Blaming..."
59
-
60
- lines.each_with_index do |todo, i|
61
- todo.populate_blame
62
- $stdout.write "\rBlamed: #{i}/#{lines.count}"
63
- end
64
-
65
- lines
66
- end
67
- end
68
-
69
- def self.history(options)
70
- GitRepo.new(options[:path]).run do |path|
71
- log = GitCommand.new(:log, [['-G', 'TODO'], ['--format="COMMIT %an %ae %at"'], ['-p'], ['-U0']])
72
-
73
- history = []
74
-
75
- blame_hash = {}
76
-
77
- puts "Going through log..."
78
- patch_additions = []
79
- patch_deletions = []
80
- filename = nil
81
- total = log.output_lines.count
82
-
83
- log.output_lines.reverse.each do |line|
84
- if (summary = /^COMMIT (.*) (.*) (.*)/.match(line))
85
- name = summary[1]
86
- email = summary[2]
87
- time = summary[3]
88
-
89
- unless filename =~ TodoLine::IGNORE
90
- # Put the additions in the blame hash so when someone removes we
91
- # can tell who the original author was. Mrrrh, this isn't going to
92
- # work if people add the same string (pretty common e.g. # TODO).
93
- # We can figure this out later though.
94
- patch_additions.each do |line|
95
- blame_hash[line] ||= []
96
- blame_hash[line] << name
97
- end
98
-
99
- deletions_by_author = {}
100
- patch_deletions.each do |line|
101
- author = blame_hash[line] && blame_hash[line].pop
102
-
103
- if author
104
- deletions_by_author[author] ||= 0
105
- deletions_by_author[author] += 1
106
- else
107
- puts "BAD BAD can't find original author: #{line}"
108
- end
109
- end
28
+ history = GitHistory.new
29
+ buckets, authors = history.timeline!
110
30
 
111
- history << {
112
- :timestamp => time.to_i,
113
- :author => name,
114
- :additions => patch_additions.count,
115
- :deletions => deletions_by_author[name] || 0
116
- }
117
-
118
- deletions_by_author.delete(name)
119
- deletions_by_author.each do |author, deletion_count|
120
- history << {
121
- :timestamp => time.to_i,
122
- :author => author,
123
- :additions => 0,
124
- :deletions => deletion_count
125
- }
126
- end
127
- end
128
-
129
- patch_additions = []
130
- patch_deletions = []
131
- elsif (diff = /diff --git a\/(.*) b\/(.*)/.match(line))
132
- filename = diff[1]
133
- elsif (diff = /^\+(.*TODO.*)/.match(line))
134
- patch_additions << diff[1]
135
- elsif (diff = /^\-(.*TODO.*)/.match(line))
136
- patch_deletions << diff[1]
137
- end
138
- end
139
-
140
- history.sort_by! {|slice| slice[:timestamp]}
141
- min_commit_date = Time.at(history.first[:timestamp])
142
- max_commit_date = Time.at(history.last[:timestamp])
143
-
144
- timespan = max_commit_date - min_commit_date
145
-
146
- # Figure out what the interval should be based on the total timespan.
147
- if timespan > 86400 * 365 * 10 # 10+ years
148
- interval = 86400 * 365 # years
149
- elsif timespan > 86400 * 365 * 5 # 5-10 years
150
- interval = 86400 * (365 / 2) # 6 months
151
- elsif timespan > 86400 * 365 # 2-5 years
152
- interval = 86400 * (365 / 4) # 3 months
153
- elsif timespan > 86400 * 30 * 6 # 6 months-3 year
154
- interval = 86400 * 30 # months
155
- elsif timespan > 86400 * 1 # 1 month - 6 months
156
- interval = 86400 * 7
157
- else # 0 - 2 months
158
- interval = 86400 # days
159
- end
160
-
161
- original_interval_start = Time.new(min_commit_date.year, min_commit_date.month, min_commit_date.day).to_i
162
- interval_start = original_interval_start
163
- interval_end = interval_start + interval
164
-
165
- puts "Finalizing timeline..."
166
- buckets = []
167
- current_bucket_authors = {}
168
- bucket_total = 0
169
-
170
- i = 0
171
- # Going through the entire history of +/-'s of TODOs.
172
- while i < history.length
173
- should_increment = false
174
- slice = history[i]
175
- author = slice[:author]
176
-
177
- # Does the current slice exist inside the bucket we're currently
178
- # in? If so, add it to the author's total and go to the next slice.
179
- if slice[:timestamp] >= interval_start && slice[:timestamp] < interval_end
180
- current_bucket_authors[author] ||= 0
181
- current_bucket_authors[author] += slice[:additions] - slice[:deletions]
182
- bucket_total += slice[:additions] - slice[:deletions]
183
- should_increment = true
184
- end
185
-
186
- # If we're on the last slice, or the next slice would have been
187
- # in a new bucket, finish the current bucket.
188
- if i == (history.length - 1) || history[i + 1][:timestamp] >= interval_end
189
- buckets << {
190
- :timestamp => Time.at(interval_start).strftime('%D'),
191
- :authors => current_bucket_authors,
192
- :total => bucket_total
193
- }
194
- interval_start += interval
195
- interval_end += interval
196
-
197
- current_bucket_authors = current_bucket_authors.clone
198
- end
199
-
200
- i += 1 if should_increment
201
- end
202
-
203
- authors = Set.new
204
- contains_other = false
205
- buckets.each do |bucket|
206
- significant_authors = {}
207
- other_count = 0
208
- bucket[:authors].each do |author, count|
209
- # Only include the author if they account for more than > 3% of
210
- # the TODOs in this bucket.
211
- if count > bucket[:total] * 0.03
212
- significant_authors[author] = count
213
- authors << author
214
- else
215
- other_count += count
216
- end
217
- end
218
-
219
- if other_count > 0
220
- significant_authors['Other'] = other_count
221
- contains_other = true
31
+ lines = TodoLine.all(["TODO"])
32
+ lines.each do |todo|
33
+ blames = history.blames[todo.raw_content]
34
+
35
+ if blames && (metadata = blames.pop)
36
+ todo.author = metadata[:name]
37
+ todo.timestamp = metadata[:time]
38
+ else
39
+ todo.author = "(Not yet committed)"
40
+ todo.timestamp = Time.now.to_i
222
41
  end
223
-
224
- bucket[:authors] = significant_authors
225
- end
226
-
227
- if contains_other
228
- authors << 'Other'
229
42
  end
230
43
 
44
+ file_path = HTMLGenerator.generate(:all, :all_lines => lines.sort_by(&:timestamp).reverse)
231
45
  file_path = HTMLGenerator.generate(:history, :data => {:history => buckets.map {|h| h[:authors].merge('Date' => h[:timestamp]) }, :authors => authors.to_a})
232
46
  Launchy.open("file://#{file_path}")
233
47
  end
234
48
  end
49
+
50
+ def self.clear(options)
51
+ puts "Deleting cache..."
52
+ Cache.clear!
53
+ puts "Done!"
54
+ end
235
55
  end
236
56
  end