stud-finder 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.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module StudFinder
6
+ class Churn
7
+ class Result
8
+ attr_reader :churn_commits, :churn_lines, :zero_inflated, :zero_percentage
9
+
10
+ def initialize(zero_inflated:, zero_percentage:, **values)
11
+ @churn_commits = values[:churn_commits] || values[:counts] || {}
12
+ @churn_lines = values[:churn_lines] || values[:line_counts] || {}
13
+ @zero_inflated = zero_inflated
14
+ @zero_percentage = zero_percentage
15
+ end
16
+
17
+ def counts
18
+ churn_commits
19
+ end
20
+
21
+ def line_counts
22
+ churn_lines
23
+ end
24
+ end
25
+
26
+ class Error < StandardError; end
27
+
28
+ def initialize(repo_path:, files:, days:, stderr: $stderr)
29
+ @repo_path = File.expand_path(repo_path)
30
+ @files = files
31
+ @days = days
32
+ @stderr = stderr
33
+ end
34
+
35
+ def call
36
+ stdout, _stderr, status = git_log
37
+ raise Error, "Error: #{@repo_path} is not a git repository." unless status.success?
38
+
39
+ counts = initial_counts
40
+ file_set = counts.keys.to_h { |file| [file, true] }
41
+ line_counts = initial_counts
42
+ stdout.each_line do |line|
43
+ line = line.strip
44
+ next if line.empty?
45
+
46
+ added, deleted, path = line.split("\t", 3)
47
+ next if path.nil?
48
+
49
+ relative = normalize_path(path)
50
+ next unless file_set[relative]
51
+
52
+ counts[relative] += 1
53
+ line_counts[relative] += added.to_i + deleted.to_i if added != '-' && numeric?(deleted)
54
+ end
55
+
56
+ Result.new(
57
+ churn_commits: counts,
58
+ churn_lines: line_counts,
59
+ zero_inflated: zero_inflated?(counts),
60
+ zero_percentage: zero_percentage(counts)
61
+ ).tap { |result| warn_if_zero_inflated(result) }
62
+ rescue Errno::ENOENT
63
+ raise Error, 'Error: git not found in PATH.'
64
+ end
65
+
66
+ private
67
+
68
+ def git_log
69
+ Open3.capture3(
70
+ 'git', '-C', @repo_path, 'log',
71
+ "--since=#{@days} days ago",
72
+ '--format=tformat:',
73
+ '--numstat',
74
+ '--no-merges',
75
+ '--diff-filter=ACDMR'
76
+ )
77
+ end
78
+
79
+ def initial_counts
80
+ @files.to_h { |file| [file, 0] }
81
+ end
82
+
83
+ def normalize_path(path)
84
+ absolute = File.expand_path(path, @repo_path)
85
+ absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
86
+ end
87
+
88
+ def numeric?(value)
89
+ value&.match?(/\A\d+\z/)
90
+ end
91
+
92
+ def zero_inflated?(counts)
93
+ return false if counts.empty?
94
+
95
+ counts.values.count(&:zero?) > counts.length * 0.5
96
+ end
97
+
98
+ def zero_percentage(counts)
99
+ return 0 if counts.empty?
100
+
101
+ ((counts.values.count(&:zero?).to_f / counts.length) * 100).round
102
+ end
103
+
104
+ def warn_if_zero_inflated(result)
105
+ return unless result.zero_inflated
106
+
107
+ @stderr.puts "Warning: #{result.zero_percentage}% of files have zero churn in the last #{@days} days. " \
108
+ 'Churn signal is weak. Consider --churn-days to widen the window.'
109
+ end
110
+ end
111
+ end