todidnt 0.2.0 → 0.3.1

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