require 'chronic' require 'sexp_processor' require 'ruby_parser' require 'json' require 'hirb' require 'fileutils' $LOAD_PATH.unshift(File.dirname(__FILE__)) require 'source_control' require 'git_analyzer' require 'svn_analyzer' require 'hg_analyzer' require 'bzr_analyzer' require 'location_mapping' require 'churn_history' require 'churn_options' module Churn # The work horse of the the churn library. # This class takes user input, determins the SCM the user is using. # It then determines changes made during this revision. # Finally it reads all the changes from previous revisions and displays human readable output on the command line. # It can also ouput a yaml format readable by other tools such as metric_fu and Caliper. class ChurnCalculator # intialized the churn calculator object def initialize(options={}) @churn_options = ChurnOptions.instance.set_options(options) @minimum_churn_count = @churn_options.minimum_churn_count @ignore_files = @churn_options.ignore_files start_date = @churn_options.start_date @source_control = set_source_control(start_date) @changes = {} @revision_changes = {} @class_changes = {} @method_changes = {} end # prepares the data for the given project to be reported. # reads git/svn logs analyzes the output, generates a report and either formats as a nice string or returns hash. # @param [Bolean] format to return the data, true for string or false for hash # @return [Object] returns either a pretty string or a hash representing the chrun of the project def report(print = true) if @churn_options.history generate_history else self.emit self.analyze print ? self.to_s : self.to_h end end # this method generates the past history of a churn project from first commit to current # running the report for oldest commits first so they are built up correctly def generate_history if @source_control.is_a?(GitAnalyzer) begin history_starting_point = Chronic.parse(@churn_options.history) @source_control.get_commit_history.each do |commit| `git checkout #{commit}` commit_date = `git show -s --format="%ci"` commit_date = Time.parse(commit_date) next if commit_date < history_starting_point #7776000 == 3.months without adding active support depenancy start_date = (commit_date - 7776000) `churn -s "#{start_date}"` end ensure `git checkout master` end "churn history complete, this has munipulated git please make sure you are back on HEAD where you expect to be" else raise "currently generate history only supports git" end end # Emits various data from source control to be analyses later... Currently this is broken up like this as a throwback to metric_fu def emit @changes = parse_log_for_changes.reject {|file, change_count| change_count < @minimum_churn_count || @ignore_files.include?(file) } @revisions = parse_log_for_revision_changes end # Analyze the source control data, filter, sort, and find more information on the editted files def analyze @changes = sort_changes(@changes) @changes = @changes.map {|file_path, times_changed| {:file_path => file_path, :times_changed => times_changed }} calculate_revision_changes @method_changes = sort_changes(@method_changes) @method_changes = @method_changes.map {|method, times_changed| {'method' => method, 'times_changed' => times_changed }} @class_changes = sort_changes(@class_changes) @class_changes = @class_changes.map {|klass, times_changed| {'klass' => klass, 'times_changed' => times_changed }} end # collect all the data into a single hash data structure. def to_h hash = {:churn => {:changes => @changes}} hash[:churn][:class_churn] = @class_changes hash[:churn][:method_churn] = @method_changes #detail the most recent changes made this revision first_revision = @revisions.first first_revision_changes = @revision_changes[first_revision] if first_revision_changes changes = first_revision_changes hash[:churn][:changed_files] = changes[:files] hash[:churn][:changed_classes] = changes[:classes] hash[:churn][:changed_methods] = changes[:methods] end #TODO crappy place to do this but save hash to revision file but while entirely under metric_fu only choice ChurnHistory.store_revision_history(first_revision, hash) hash end def to_s ChurnCalculator.to_s(to_h[:churn]) end # Pretty print the data as a string for the user def self.to_s(hash) result = seperator result +="* Revision Changes \n" result += seperator result += "Files: \n" result += display_array(hash[:changed_files], :fields=>[:to_str], :headers=>{:to_str=>'file'}) result += "\nClasses: \n" result += display_array(hash[:changed_classes]) result += "\nMethods: \n" result += display_array(hash[:changed_methods]) + "\n" result += seperator result +="* Project Churn \n" result += seperator result += "Files: \n" result += display_array(hash[:changes]) result += "\nClasses: \n" class_churn = collect_items(hash[:class_churn], 'klass') result += display_array(class_churn) result += "\nMethods: \n" method_churn = collect_items(hash[:method_churn], 'method') result += display_array(method_churn) end private def self.collect_items(collection, match) return [] unless collection collection.map {|item| (item.delete(match) || {}).merge(item) } end def sort_changes(changes) changes.to_a.sort! {|first,second| second[1] <=> first[1]} end def filters /.*\.rb/ end def self.display_array(array, options={}) array ? Hirb::Helpers::AutoTable.render(array, options.merge(:description=>false)) + "\n" : '' end def self.seperator "*"*70+"\n" end def self.git? !!(`git branch 2>&1` && $?.success?) end def self.hg? !!(`hg branch 2>&1` && $?.success?) end def self.bzr? !!(`bzr nick 2>&1` && $?.success?) end def set_source_control(start_date) if self.class.git? GitAnalyzer.new(start_date) elsif self.class.hg? HgAnalyzer.new(start_date) elsif self.class.bzr? BzrAnalyzer.new(start_date) elsif File.exist?(".svn") SvnAnalyzer.new(start_date) else raise "Churning requires a bazaar, git, mercurial, or subversion repo" end end def calculate_revision_changes @revisions.each do |revision| if revision == @revisions.first #can't iterate through all the changes and tally them up #it only has the current files not the files at the time of the revision #parsing requires the files changed_files, changed_classes, changed_methods = calculate_revision_data(revision) else changed_files, changed_classes, changed_methods = ChurnHistory.load_revision_data(revision) end calculate_changes!(changed_methods, @method_changes) if changed_methods calculate_changes!(changed_classes, @class_changes) if changed_classes @revision_changes[revision] = { :files => changed_files, :classes => changed_classes, :methods => changed_methods } end end def calculate_revision_data(revision) changed_files = parse_logs_for_updated_files(revision, @revisions) changed_classes = [] changed_methods = [] changed_files.each do |file_changes| if file_changes.first.match(filters) classes, methods = get_changes(file_changes) changed_classes += classes changed_methods += methods end end changed_files = changed_files.map { |file, lines| file } [changed_files, changed_classes, changed_methods] end def calculate_changes!(changed_objs, total_changes) if changed_objs changed_objs.each do |change| total_changes.include?(change) ? total_changes[change] = total_changes[change]+1 : total_changes[change] = 1 end end total_changes end def get_changes(change) file = change.first breakdown = LocationMapping.new breakdown.get_info(file) changes = change.last classes = changes_for_type(changes, breakdown.klasses_collection) methods = changes_for_type(changes, breakdown.methods_collection) classes = classes.map{ |klass| {'file' => file, 'klass' => klass} } methods = methods.map{ |method| {'file' => file, 'klass' => get_klass_for(method), 'method' => method} } [classes, methods] rescue => error [[],[]] end def get_klass_for(method) method.gsub(/(#|\.).*/,'') end def changes_for_type(changes, item_collection) changed_items = [] item_collection.each_pair do |item, item_lines| item_lines = item_lines[0].to_a changes.each do |change_range| item_lines.each do |line| changed_items << item if change_range.include?(line) && !changed_items.include?(item) end end end changed_items end def parse_log_for_changes changes = {} logs = @source_control.get_logs logs.each do |line| changes[line] ? changes[line] += 1 : changes[line] = 1 end changes end def parse_log_for_revision_changes return [] unless @source_control.respond_to?(:get_revisions) @source_control.get_revisions end def parse_logs_for_updated_files(revision, revisions) #TODO SVN doesn't support this return {} unless @source_control.respond_to?(:get_updated_files_change_info) files = @source_control.get_updated_files_change_info(revision, revisions) files.select{ |file, value| !@ignore_files.include?(file) } end end end