Class: GitOwnershipInsights

Inherits:
Object
  • Object
show all
Defined in:
lib/git_ownership_insights.rb,
lib/git_ownership_insights/version.rb,
lib/git_ownership_insights/git_ownership_insight.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

VERSION =
'2.0.8'

Instance Method Summary collapse

Constructor Details

#initialize(directory_path:, duration_in_days:, begin_time:, debug: nil, steps: 1) ⇒ GitOwnershipInsights

Returns a new instance of GitOwnershipInsights.



7
8
9
10
11
12
13
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 7

def initialize(directory_path:, duration_in_days:, begin_time:, debug: nil, steps: 1)
  @directory_path = directory_path
  @duration_in_days = duration_in_days
  @begin_time = begin_time
  @debug = debug
  @steps = steps
end

Instance Method Details

#analyze_changed_files(uniq_code_files_with_changes:, start_date:, end_date:) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 179

def analyze_changed_files(uniq_code_files_with_changes:, start_date:, end_date:)
  all_teams = []
  cross_teams_count = 0
  single_ownership_teams_count = 0
  files_changed_by_many_teams = 0
  total_changes = 0
  file_team_map = {}
  uniq_code_files_with_changes.each do |file|
    filename = File.basename(file)
    commit_count = git_commit_count(file:, start_date:, end_date:).to_i
    git_log = git_commit_info(file:, start_date:, end_date:).split("\n")
    teams = git_log.map do |team|
      team.match(/#{TEAM_REGEX}/)[0].upcase
    end.reject { |e| EXCLUSIONS&.include?(e) }

    total_changes += commit_count
    all_teams << teams
    teams = teams.uniq

    if teams.count > 1
      files_changed_by_many_teams += 1
      file_team_map.merge!(file.to_s => [teams, commit_count])
      cross_teams_count += teams.count
    else
      single_ownership_teams_count += 1
    end

    puts "\n#{filename} [#{commit_count}]:#{teams}\n" if @debug
  end
  [all_teams, cross_teams_count, single_ownership_teams_count, files_changed_by_many_teams, total_changes, file_team_map]
end

#contribution_messageObject



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 220

def contribution_message
  duration_in_days = @duration_in_days.to_i
  start_date = @begin_time.to_time.to_i - duration_in_days * 86_400 - 30 * 86_400
  end_date = @begin_time.to_time.to_i - 30 * 86_400
  git_ls = git_files(directory_path: @directory_path)
  file_count = filter_existing_code_files(git_ls.split).count
  all_files_with_changes = files_with_changes(directory_path: @directory_path, start_date:, end_date:).split.sort
  code_files_with_changes = filter_existing_code_files(all_files_with_changes)
  uniq_code_files_with_changes = code_files_with_changes.uniq
  all_teams, cross_teams_count, single_ownership_teams_count, files_changed_by_many_teams, total_changes, file_team_map = analyze_changed_files(uniq_code_files_with_changes:, start_date:, end_date:)
  occurrences = all_teams.flatten.compact.tally
  sorted_occurrences = occurrences.sort_by { |element, count| [-count, element] }
  contributors = Hash[sorted_occurrences]
  churn_count = file_team_map.values.map { |value| value[1] }.sum
  hotspot_changes_percentage = (churn_count.to_f / total_changes) * 100
  # Filter files based on extension, existence and size
  filtered_files = filter_files(file_team_map:)
  filtered_top_touched_files = filtered_files.sort_by { |element, count| [-count.last, element] }

  puts ''
  puts "*Timeframe:* #{(@begin_time - duration_in_days).strftime('%Y-%m-%d')} to #{@begin_time.strftime('%Y-%m-%d')}"
  puts "     *Code files with a single contributor:* #{(100 - ((files_changed_by_many_teams.to_f / file_count) * 100)).round(2)}%"
  puts "        *Existing files changed by many teams:* #{files_changed_by_many_teams}"
  puts "        *Current existing #{CODE_EXTENSIONS} files:* #{file_count}"
  puts '     *Cross-Squad Dependency:*'
  puts "        *Contributions by multiple squads to the same files:* #{cross_teams_count}"
  puts "        *Contributions by single squads contributing to single files:* #{single_ownership_teams_count}"
  puts "     *Hotspot Code Changes:* #{hotspot_changes_percentage.round(2)}%"
  puts "        *Churn count(commits to files by multiple teams):* #{churn_count}"
  puts "        *Total amount of commits:* #{total_changes}"
  count_hotspot_lines(filtered_files.keys)
  puts "     *#{CODE_EXTENSIONS} files with multiple contributors:* #{file_team_map.count}"
  puts "     *#{CODE_EXTENSIONS} files exceeding #{BIG_FILE_SIZE} lines with multiple contributors:* #{filtered_top_touched_files.count}"
  puts "     *Total amount of commits to #{CODE_EXTENSIONS} files:* #{total_changes}"
  puts "     *Total #{CODE_EXTENSIONS} files changed:* #{uniq_code_files_with_changes.count}"
  count_big_files(@directory_path)
  puts "     *Current total of #{CODE_EXTENSIONS} files in the folder:* #{file_count}"
  puts "     *Contributors:* #{contributors}"

  if HOTSPOT
    hotspot_output = "\n   *Hotspot files(#{filtered_top_touched_files.count}):*\n"

    filtered_top_touched_files.each do |line|
      hotspot_output += "\n"
      file = line.first
      contributors = line.last.first
      commits = line.last.last
      hotspot_output += "     #{file.gsub(@directory_path, '')} Contributors: #{contributors} Commits: #{commits} Owner: #{find_owner(file:)}\n"
    end

    if FILE_OUTPUT
      File.open('hotspot.txt', 'w') do |f|
        f.puts hotspot_output
      end
    else
      puts hotspot_output
    end
  end

  handle_codeowners(file_team_map:) if CODEOWNERS

  @steps -= 1

  return unless @steps.positive?

  system("git checkout `git rev-list -1 --before='#{(@begin_time - duration_in_days).strftime('%B %d %Y')}' HEAD`",
         %i[out err] => File::NULL)
  @begin_time -= duration_in_days
  contribution_message
end

#count_big_files(directory_path, size: BIG_FILE_SIZE) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 107

def count_big_files(directory_path, size: BIG_FILE_SIZE)
  size = size.to_i
  # Get a list of all files in the specified directory
  files = Dir.glob(File.join(directory_path, '**', '*')).select { |file| File.file?(file) }

  code_files = files.select do |f|
    extension = File.extname(f)
    valid_extensions = CODE_EXTENSIONS
    valid_extensions.include?(extension)
  end

  # Initialize a counter for files that meet the criteria
  count = 0
  # Iterate through each file and check the line count
  code_files.each do |file|
    lines_count = File.foreach(file).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count

    count += 1 if lines_count > size
  end

  puts "     *Current total number of code files longer than #{size} lines:* #{count}"
end

#count_hotspot_lines(files) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 130

def count_hotspot_lines(files)
  code_files = files.select do |f|
    extension = File.extname(f)
    valid_extensions = CODE_EXTENSIONS
    valid_extensions.include?(extension)
  end

  count = 0

  code_files.each do |file|
    lines_count = File.foreach(file).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count

    count += lines_count
  end

  puts "     *Total lines of hotspot code:* #{count}"
end

#files_with_changes(directory_path:, start_date:, end_date:) ⇒ Object



167
168
169
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 167

def files_with_changes(directory_path:, start_date:, end_date:)
  `git log --name-only --pretty=format:"" --since="#{start_date}" --until="#{end_date}" "#{directory_path}"`
end

#filter_existing_code_files(files) ⇒ Object



148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 148

def filter_existing_code_files(files)
  files.select do |f|
    next unless File.exist?(f)

    if EXCLUDED_FILES
      excluded_patterns = EXCLUDED_FILES.split(',')
      next if excluded_patterns.any? { |pattern| f.include?(pattern) }
    end

    extension = File.extname(f)
    valid_extensions = CODE_EXTENSIONS
    valid_extensions.include?(extension)
  end
end

#filter_files(file_team_map:) ⇒ Object



211
212
213
214
215
216
217
218
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 211

def filter_files(file_team_map:)
  file_team_map.select do |file_path|
    next unless File.exist?(file_path)

    # Check if the file size is more than BIG_FILE_SIZE lines (excluding empty and commented lines)
    File.foreach(file_path).reject { |line| line.match(%r{^\s*(//|/\*.*\*/|\s*$)}) }.count > BIG_FILE_SIZE.to_i
  end
end

#find_owner(file:) ⇒ Object



102
103
104
105
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 102

def find_owner(file:)
  codeowners = read_codeowners_file
  find_owners(file, codeowners)
end

#find_owners(file_path, codeowners) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 34

def find_owners(file_path, codeowners)
  matching_patterns = codeowners.keys.select do |pattern|
    pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**',
                                                                                                            '.*?')}")
    file_path =~ pattern_regex
  end

  return ['unknown'] if matching_patterns.empty?

  # Sort patterns by length in descending order
  sorted_patterns = matching_patterns.sort_by(&:length).reverse

  # Find the most specific matching pattern
  best_match = sorted_patterns.find do |pattern|
    pattern_regex = Regexp.new("^#{Regexp.escape(pattern.sub(%r{^/+}, '').chomp('/')).gsub('\*', '.*').gsub('**',
                                                                                                            '.*?')}")
    file_path =~ pattern_regex
  end

  codeowners[best_match].split(' ')
end

#git_commit_count(file:, start_date:, end_date:) ⇒ Object



171
172
173
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 171

def git_commit_count(file:, start_date:, end_date:)
  `git log --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}" | grep -c '^commit'`
end

#git_commit_info(file:, start_date:, end_date:) ⇒ Object



175
176
177
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 175

def git_commit_info(file:, start_date:, end_date:)
  `git log --pretty=format:"%s" --since="#{start_date}" --until="#{end_date}" --follow -- "#{file}"`
end

#git_files(directory_path:) ⇒ Object



163
164
165
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 163

def git_files(directory_path:)
  `git ls-tree -r --name-only $(git rev-list -1 HEAD) -- "#{directory_path}"`
end

#handle_codeowners(file_team_map:) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 56

def handle_codeowners(file_team_map:)
  output = "\n   *Code ownership data:*\n"
  codeowners = read_codeowners_file

  owners_data = Hash.new do |hash, key|
    hash[key] = { directories: Hash.new do |h, k|
      h[k] = { files: [] }
    end, churn_count: 0 }
  end

  file_team_map.each do |file, count|
    owners = find_owners(file, codeowners)
    owners.each do |owner|
      owners_data[owner][:churn_count] += count.last

      dir_path = File.dirname(file)
      owners_data[owner][:directories][dir_path][:files] << { name: File.basename(file), count: }
    end
  end

  # Sort owners_data by total count in descending order
  sorted_owners_data = owners_data.sort_by { |_, data| -data[:churn_count] }
  converted_team_map = file_team_map.transform_keys { |key| File.basename(key) }

  sorted_owners_data.each do |owner, data|
    output += "\n      #{owner.split('/').last}:\n         Total Count: #{data[:churn_count]}\n"
    data[:directories].each do |dir, dir_data|
      output += "            Directory: #{dir}\n               Top files:\n"
      dir_data[:files].each do |file_data|
        next if converted_team_map[File.basename(file_data[:name])].nil?

        contributors = converted_team_map[file_data[:name]]&.first&.empty? ? ['Excluded contributor'] : converted_team_map[file_data[:name]].first
        output += "                  #{File.basename(file_data[:name])} - #{file_data[:count].last} #{contributors}}\n"
      end
    end
  end

  if FILE_OUTPUT
    File.open('codeowners.txt', 'w') do |f|
      f.puts output
    end
  else
    puts output
  end
end

#read_codeowners_fileObject



19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 19

def read_codeowners_file
  raise "CODEOWNERS file does not exist under #{CODEOWNERS_PATH}" unless File.exist?(CODEOWNERS_PATH)

  codeowners = {}
  File.readlines(CODEOWNERS_PATH).each do |line|
    next if line.strip.empty? || line.start_with?('#') # Skip comments and empty lines

    parts = line.split(/\s+/)
    directory_pattern = parts[0]
    owner = parts[1..].map { |o| o.start_with?('@') ? o[1..] : o }.join(' ') # Remove leading '@' from team names
    codeowners[directory_pattern] = owner
  end
  codeowners
end

#true?(obj) ⇒ Boolean

Returns:

  • (Boolean)


15
16
17
# File 'lib/git_ownership_insights/git_ownership_insight.rb', line 15

def true?(obj)
  obj.to_s.downcase == 'true'
end