ruby-maat 1.0.0 → 1.3.4
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.
- checksums.yaml +4 -4
- data/.commitlintrc.json +2 -1
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +23 -1
- data/.standard.yml +3 -0
- data/CHANGELOG.md +178 -0
- data/CLAUDE.md +100 -50
- data/README.md +99 -8
- data/lib/ruby_maat/analysis/churn.rb +2 -2
- data/lib/ruby_maat/analysis/effort.rb +5 -5
- data/lib/ruby_maat/analysis_presets.rb +313 -0
- data/lib/ruby_maat/app.rb +25 -0
- data/lib/ruby_maat/change_record.rb +13 -3
- data/lib/ruby_maat/cli.rb +259 -5
- data/lib/ruby_maat/generators/base_generator.rb +289 -0
- data/lib/ruby_maat/generators/git_generator.rb +207 -0
- data/lib/ruby_maat/generators/svn_generator.rb +201 -0
- data/lib/ruby_maat/groupers/merge_commit_grouper.rb +152 -0
- data/lib/ruby_maat/parsers/git2_parser.rb +33 -10
- data/lib/ruby_maat/parsers/git_parser.rb +2 -2
- data/lib/ruby_maat/parsers/mercurial_parser.rb +1 -1
- data/lib/ruby_maat/parsers/perforce_parser.rb +2 -2
- data/lib/ruby_maat/parsers/tfs_parser.rb +3 -3
- data/lib/ruby_maat/vcs_detector.rb +75 -0
- data/lib/ruby_maat/version.rb +1 -1
- data/lib/ruby_maat.rb +1 -0
- data/release-please-config.json +57 -0
- metadata +19 -11
- data/.mailmap +0 -3
- data/.release-please-config.json +0 -33
- data/CI_CD_SETUP.md +0 -180
- data/README_RUBY.md +0 -300
- data/RELEASE_PLEASE_SETUP.md +0 -198
- data/RUBY_MAAT.md +0 -227
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "base_generator"
|
|
5
|
+
|
|
6
|
+
module RubyMaat
|
|
7
|
+
module Generators
|
|
8
|
+
class GitGenerator < BaseGenerator
|
|
9
|
+
PRESETS = {
|
|
10
|
+
"git2-format" => {
|
|
11
|
+
description: "Standard format for git2 parser (recommended)",
|
|
12
|
+
options: {
|
|
13
|
+
format: "git2",
|
|
14
|
+
no_renames: true,
|
|
15
|
+
all_branches: true
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"git-legacy" => {
|
|
19
|
+
description: "Legacy format for git parser",
|
|
20
|
+
options: {
|
|
21
|
+
format: "legacy",
|
|
22
|
+
no_renames: true,
|
|
23
|
+
all_branches: false
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"recent-activity" => {
|
|
27
|
+
description: "Last 3 months of activity",
|
|
28
|
+
options: {
|
|
29
|
+
format: "git2",
|
|
30
|
+
since: (Date.today - 90).strftime("%Y-%m-%d"),
|
|
31
|
+
no_renames: true,
|
|
32
|
+
all_branches: true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"last-year" => {
|
|
36
|
+
description: "Last 12 months of activity",
|
|
37
|
+
options: {
|
|
38
|
+
format: "git2",
|
|
39
|
+
since: (Date.today - 365).strftime("%Y-%m-%d"),
|
|
40
|
+
no_renames: true,
|
|
41
|
+
all_branches: true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"full-history" => {
|
|
45
|
+
description: "Complete repository history (may be large)",
|
|
46
|
+
options: {
|
|
47
|
+
format: "git2",
|
|
48
|
+
no_renames: true,
|
|
49
|
+
all_branches: true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"pr-coupling" => {
|
|
53
|
+
description: "Format with parent hashes for PR-level coupling analysis (use with --group-by-merge)",
|
|
54
|
+
options: {
|
|
55
|
+
format: "git2-parents",
|
|
56
|
+
no_renames: true,
|
|
57
|
+
all_branches: true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
def available_presets
|
|
63
|
+
PRESETS
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
protected
|
|
67
|
+
|
|
68
|
+
def validate_repository!
|
|
69
|
+
super
|
|
70
|
+
|
|
71
|
+
git_dir = File.join(@repository_path, ".git")
|
|
72
|
+
unless Dir.exist?(git_dir) || File.exist?(git_dir)
|
|
73
|
+
raise ArgumentError, "Not a Git repository: #{@repository_path}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_command(options)
|
|
78
|
+
format = options[:format] || "git2"
|
|
79
|
+
|
|
80
|
+
case format
|
|
81
|
+
when "git2"
|
|
82
|
+
build_git2_command(options)
|
|
83
|
+
when "git2-parents"
|
|
84
|
+
build_git2_parents_command(options)
|
|
85
|
+
when "legacy"
|
|
86
|
+
build_legacy_command(options)
|
|
87
|
+
else
|
|
88
|
+
raise ArgumentError, "Unknown Git format: #{format}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def gather_vcs_specific_options(options)
|
|
93
|
+
super
|
|
94
|
+
|
|
95
|
+
puts "\nGit-specific options:"
|
|
96
|
+
|
|
97
|
+
# Format selection
|
|
98
|
+
puts "Output formats:"
|
|
99
|
+
puts " 1. git2 (recommended, faster parsing)"
|
|
100
|
+
puts " 2. git2-parents (git2 with parent hashes, for --group-by-merge)"
|
|
101
|
+
puts " 3. legacy (backward compatibility)"
|
|
102
|
+
|
|
103
|
+
format_choice = ask_integer("Choose format", 1, 3)
|
|
104
|
+
formats = {1 => "git2", 2 => "git2-parents", 3 => "legacy"}
|
|
105
|
+
options[:format] = formats[format_choice]
|
|
106
|
+
|
|
107
|
+
# Branch selection
|
|
108
|
+
options[:all_branches] = ask_yes_no("Include all branches?", options[:all_branches])
|
|
109
|
+
|
|
110
|
+
# Rename detection
|
|
111
|
+
options[:no_renames] = ask_yes_no("Disable rename detection? (recommended for performance)",
|
|
112
|
+
options.fetch(:no_renames, true))
|
|
113
|
+
|
|
114
|
+
# Author filtering
|
|
115
|
+
author = ask_string("Filter by author (empty for all)")
|
|
116
|
+
options[:author] = author unless author.empty?
|
|
117
|
+
|
|
118
|
+
# Path filtering
|
|
119
|
+
path = ask_string("Filter by path pattern (empty for all files)")
|
|
120
|
+
options[:path] = path unless path.empty?
|
|
121
|
+
|
|
122
|
+
options
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def build_git2_command(options)
|
|
128
|
+
parts = ["git", "log"]
|
|
129
|
+
|
|
130
|
+
# Core git2 format options
|
|
131
|
+
parts << "--all" if options[:all_branches]
|
|
132
|
+
parts << "--numstat"
|
|
133
|
+
parts << "--date=short"
|
|
134
|
+
parts << "--pretty=format:'--%h--%ad--%aN--%s'"
|
|
135
|
+
parts << "--no-renames" if options[:no_renames]
|
|
136
|
+
|
|
137
|
+
# Date filtering (with validation and shell escaping)
|
|
138
|
+
parts << "--after=#{shell_escape(validate_date(options[:since]))}" if options[:since]
|
|
139
|
+
parts << "--before=#{shell_escape(validate_date(options[:until]))}" if options[:until]
|
|
140
|
+
|
|
141
|
+
# Author filtering (with shell escaping)
|
|
142
|
+
parts << "--author=#{shell_escape(options[:author])}" if options[:author]
|
|
143
|
+
|
|
144
|
+
# Add path at the end if specified (with shell escaping)
|
|
145
|
+
parts << "--" << shell_escape(options[:path]) if options[:path]
|
|
146
|
+
|
|
147
|
+
parts.join(" ")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_git2_parents_command(options)
|
|
151
|
+
parts = ["git", "log"]
|
|
152
|
+
|
|
153
|
+
parts << "--all" if options[:all_branches]
|
|
154
|
+
parts << "--numstat"
|
|
155
|
+
parts << "--date=short"
|
|
156
|
+
parts << "--pretty=format:'--%h--%p--%ad--%aN--%s'"
|
|
157
|
+
parts << "--no-renames" if options[:no_renames]
|
|
158
|
+
|
|
159
|
+
parts << "--after=#{shell_escape(validate_date(options[:since]))}" if options[:since]
|
|
160
|
+
parts << "--before=#{shell_escape(validate_date(options[:until]))}" if options[:until]
|
|
161
|
+
|
|
162
|
+
parts << "--author=#{shell_escape(options[:author])}" if options[:author]
|
|
163
|
+
|
|
164
|
+
parts << "--" << shell_escape(options[:path]) if options[:path]
|
|
165
|
+
|
|
166
|
+
parts.join(" ")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_legacy_command(options)
|
|
170
|
+
parts = ["git", "log"]
|
|
171
|
+
|
|
172
|
+
# Core legacy format options
|
|
173
|
+
parts << "--all" if options[:all_branches]
|
|
174
|
+
parts << "--pretty=format:'[%h] %aN %ad %s'"
|
|
175
|
+
parts << "--date=short"
|
|
176
|
+
parts << "--numstat"
|
|
177
|
+
parts << "--no-renames" if options[:no_renames]
|
|
178
|
+
|
|
179
|
+
# Date filtering (with validation and shell escaping)
|
|
180
|
+
parts << "--after=#{shell_escape(validate_date(options[:since]))}" if options[:since]
|
|
181
|
+
parts << "--before=#{shell_escape(validate_date(options[:until]))}" if options[:until]
|
|
182
|
+
|
|
183
|
+
# Author filtering (with shell escaping)
|
|
184
|
+
parts << "--author=#{shell_escape(options[:author])}" if options[:author]
|
|
185
|
+
|
|
186
|
+
# Add path at the end if specified (with shell escaping)
|
|
187
|
+
parts << "--" << shell_escape(options[:path]) if options[:path]
|
|
188
|
+
|
|
189
|
+
parts.join(" ")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Security methods to prevent command injection
|
|
193
|
+
def shell_escape(value)
|
|
194
|
+
return value unless value.is_a?(String)
|
|
195
|
+
# Use Shellwords.escape for proper shell escaping
|
|
196
|
+
require "shellwords"
|
|
197
|
+
Shellwords.escape(value)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_date(date)
|
|
201
|
+
# Validate date format and prevent injection
|
|
202
|
+
return date if date.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
203
|
+
raise ArgumentError, "Invalid date format: #{date}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "base_generator"
|
|
5
|
+
|
|
6
|
+
module RubyMaat
|
|
7
|
+
module Generators
|
|
8
|
+
class SvnGenerator < BaseGenerator
|
|
9
|
+
PRESETS = {
|
|
10
|
+
"standard" => {
|
|
11
|
+
description: "Standard SVN XML log format",
|
|
12
|
+
options: {
|
|
13
|
+
verbose: true,
|
|
14
|
+
xml: true
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"recent-activity" => {
|
|
18
|
+
description: "Last 3 months of activity",
|
|
19
|
+
options: {
|
|
20
|
+
verbose: true,
|
|
21
|
+
xml: true,
|
|
22
|
+
since: (Date.today - 90).strftime("%Y%m%d")
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"last-year" => {
|
|
26
|
+
description: "Last 12 months of activity",
|
|
27
|
+
options: {
|
|
28
|
+
verbose: true,
|
|
29
|
+
xml: true,
|
|
30
|
+
since: (Date.today - 365).strftime("%Y%m%d")
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"date-range" => {
|
|
34
|
+
description: "Custom date range",
|
|
35
|
+
options: {
|
|
36
|
+
verbose: true,
|
|
37
|
+
xml: true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"revision-range" => {
|
|
41
|
+
description: "Specific revision range",
|
|
42
|
+
options: {
|
|
43
|
+
verbose: true,
|
|
44
|
+
xml: true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
def available_presets
|
|
50
|
+
PRESETS
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
def validate_repository!
|
|
56
|
+
super
|
|
57
|
+
|
|
58
|
+
svn_dir = File.join(@repository_path, ".svn")
|
|
59
|
+
unless Dir.exist?(svn_dir)
|
|
60
|
+
# Check if we're in an SVN working copy by running svn info
|
|
61
|
+
begin
|
|
62
|
+
`cd "#{@repository_path}" && svn info 2>/dev/null`
|
|
63
|
+
if $?.exitstatus != 0
|
|
64
|
+
raise ArgumentError, "Not an SVN working copy: #{@repository_path}"
|
|
65
|
+
end
|
|
66
|
+
rescue
|
|
67
|
+
raise ArgumentError, "SVN not available or not an SVN working copy: #{@repository_path}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_command(options)
|
|
73
|
+
parts = ["svn", "log"]
|
|
74
|
+
|
|
75
|
+
# Core options
|
|
76
|
+
parts << "-v" if options[:verbose]
|
|
77
|
+
parts << "--xml" if options[:xml]
|
|
78
|
+
|
|
79
|
+
# Revision range (with validation and escaping)
|
|
80
|
+
if options[:revision_start] && options[:revision_end]
|
|
81
|
+
parts << "-r"
|
|
82
|
+
parts << "#{validate_revision(options[:revision_start])}:#{validate_revision(options[:revision_end])}"
|
|
83
|
+
elsif options[:revision_start]
|
|
84
|
+
parts << "-r"
|
|
85
|
+
parts << "#{validate_revision(options[:revision_start])}:HEAD"
|
|
86
|
+
elsif options[:since] || options[:until]
|
|
87
|
+
# Date-based revision range
|
|
88
|
+
revision_range = build_date_revision_range(options)
|
|
89
|
+
if revision_range
|
|
90
|
+
parts << "-r"
|
|
91
|
+
parts << revision_range
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Limit
|
|
96
|
+
parts << "-l"
|
|
97
|
+
parts << options[:limit].to_s if options[:limit]
|
|
98
|
+
|
|
99
|
+
# URL (if specified, otherwise use current directory) (with shell escaping)
|
|
100
|
+
parts << shell_escape(options[:url]) if options[:url]
|
|
101
|
+
|
|
102
|
+
parts.join(" ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def gather_vcs_specific_options(options)
|
|
106
|
+
super
|
|
107
|
+
|
|
108
|
+
puts "\nSVN-specific options:"
|
|
109
|
+
|
|
110
|
+
# Verbose output
|
|
111
|
+
options[:verbose] = ask_yes_no("Include file paths? (verbose mode)",
|
|
112
|
+
options.fetch(:verbose, true))
|
|
113
|
+
|
|
114
|
+
# XML format
|
|
115
|
+
options[:xml] = ask_yes_no("Use XML output format? (recommended)",
|
|
116
|
+
options.fetch(:xml, true))
|
|
117
|
+
|
|
118
|
+
# Revision range vs date range
|
|
119
|
+
puts "\nRange selection:"
|
|
120
|
+
puts " 1. Date range"
|
|
121
|
+
puts " 2. Revision range"
|
|
122
|
+
puts " 3. No range (full history)"
|
|
123
|
+
|
|
124
|
+
range_choice = ask_integer("Choose range type", 1, 3)
|
|
125
|
+
|
|
126
|
+
case range_choice
|
|
127
|
+
when 1
|
|
128
|
+
# Date range already handled by base class
|
|
129
|
+
when 2
|
|
130
|
+
rev_start = ask_string("Start revision (empty for beginning)")
|
|
131
|
+
options[:revision_start] = rev_start unless rev_start.empty?
|
|
132
|
+
|
|
133
|
+
rev_end = ask_string("End revision (empty for HEAD)")
|
|
134
|
+
options[:revision_end] = rev_end unless rev_end.empty?
|
|
135
|
+
|
|
136
|
+
# Clear date options if revision range is specified
|
|
137
|
+
options.delete(:since)
|
|
138
|
+
options.delete(:until)
|
|
139
|
+
when 3
|
|
140
|
+
# Clear all range options
|
|
141
|
+
options.delete(:since)
|
|
142
|
+
options.delete(:until)
|
|
143
|
+
options.delete(:revision_start)
|
|
144
|
+
options.delete(:revision_end)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Limit
|
|
148
|
+
limit = ask_string("Limit number of entries (empty for no limit)")
|
|
149
|
+
options[:limit] = limit.to_i unless limit.empty?
|
|
150
|
+
|
|
151
|
+
# URL
|
|
152
|
+
url = ask_string("SVN URL (empty to use current working copy)")
|
|
153
|
+
options[:url] = url unless url.empty?
|
|
154
|
+
|
|
155
|
+
options
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def supports_date_filtering?
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def build_date_revision_range(options)
|
|
165
|
+
if options[:since] && options[:until]
|
|
166
|
+
"{#{validate_date(options[:since])}}:{#{validate_date(options[:until])}}"
|
|
167
|
+
elsif options[:since]
|
|
168
|
+
"{#{validate_date(options[:since])}}:HEAD"
|
|
169
|
+
elsif options[:until]
|
|
170
|
+
"1:{#{validate_date(options[:until])}}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def format_date_for_svn(date_str)
|
|
175
|
+
# Convert YYYY-MM-DD to YYYYMMDD for SVN
|
|
176
|
+
Date.parse(date_str).strftime("%Y%m%d")
|
|
177
|
+
rescue Date::Error
|
|
178
|
+
date_str
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Security methods to prevent command injection
|
|
182
|
+
def shell_escape(value)
|
|
183
|
+
return value unless value.is_a?(String)
|
|
184
|
+
require "shellwords"
|
|
185
|
+
Shellwords.escape(value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_revision(revision)
|
|
189
|
+
# Only allow alphanumeric characters, dots, and basic revision keywords
|
|
190
|
+
return revision if revision.to_s.match?(/\A[a-zA-Z0-9._-]+\z/)
|
|
191
|
+
raise ArgumentError, "Invalid revision format: #{revision}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_date(date)
|
|
195
|
+
# Validate date format and prevent injection
|
|
196
|
+
return date if date.to_s.match?(/\A\d{4}-?\d{2}-?\d{2}\z/)
|
|
197
|
+
raise ArgumentError, "Invalid date format: #{date}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# NOTE: Set has been a built-in class (autoloaded without require) since
|
|
4
|
+
# Ruby 3.2, which is this project's minimum version (see gemspec:
|
|
5
|
+
# required_ruby_version >= "3.2.0"). An explicit `require "set"` is
|
|
6
|
+
# unnecessary and triggers RuboCop's Lint/RedundantRequireStatement cop.
|
|
7
|
+
|
|
8
|
+
module RubyMaat
|
|
9
|
+
module Groupers
|
|
10
|
+
# Groups commits by their merge commit to enable PR-level coupling analysis.
|
|
11
|
+
#
|
|
12
|
+
# When Git uses a merge-based workflow (e.g., GitHub PRs), individual commits
|
|
13
|
+
# on feature branches are combined into merge commits on the main branch.
|
|
14
|
+
# This grouper identifies merge commits and rewrites the revision of child
|
|
15
|
+
# commits so that all commits belonging to a merge share the same revision.
|
|
16
|
+
#
|
|
17
|
+
# This allows the coupling analysis to treat all files changed in a PR as
|
|
18
|
+
# co-changing, giving more meaningful coupling results.
|
|
19
|
+
#
|
|
20
|
+
# Requires log data generated with parent hashes:
|
|
21
|
+
# git log --all --numstat --date=short --pretty=format:'--%h--%p--%ad--%aN--%s' --no-renames
|
|
22
|
+
class MergeCommitGrouper
|
|
23
|
+
def group(change_records)
|
|
24
|
+
commit_info = build_commit_info(change_records)
|
|
25
|
+
merge_map = build_merge_map(commit_info)
|
|
26
|
+
|
|
27
|
+
return change_records if merge_map.empty?
|
|
28
|
+
|
|
29
|
+
merge_dates = build_merge_dates(commit_info)
|
|
30
|
+
rewrite_records(change_records, merge_map, merge_dates)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_commit_info(records)
|
|
36
|
+
commits = {}
|
|
37
|
+
records.each do |record|
|
|
38
|
+
rev = record.revision
|
|
39
|
+
commits[rev] ||= {parents: record.parent_revisions || [], merge: false, date: record.date}
|
|
40
|
+
commits[rev][:merge] = true if record.merge_commit?
|
|
41
|
+
end
|
|
42
|
+
commits
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_merge_map(commits)
|
|
46
|
+
merge_map = {}
|
|
47
|
+
merges = commits.select { |_, info| info[:merge] }
|
|
48
|
+
|
|
49
|
+
merges.each do |merge_rev, info|
|
|
50
|
+
parents = info[:parents] || []
|
|
51
|
+
mainline_parent = parents[0]
|
|
52
|
+
# Skip this merge if it does not have a valid mainline parent present
|
|
53
|
+
# in the commit set, or if it is not a true merge (single parent).
|
|
54
|
+
next unless mainline_parent && parents.length > 1 && commits.key?(mainline_parent)
|
|
55
|
+
|
|
56
|
+
# Compute mainline ancestors once per merge commit and reuse across
|
|
57
|
+
# all feature parents, avoiding repeated graph walks for octopus merges.
|
|
58
|
+
mainline_ancestors = collect_ancestors(mainline_parent, commits)
|
|
59
|
+
# If we cannot determine any ancestors for the mainline parent (for
|
|
60
|
+
# example due to a filtered log), skip grouping for this merge to
|
|
61
|
+
# avoid incorrect rewrites with an incomplete graph.
|
|
62
|
+
next if mainline_ancestors.empty?
|
|
63
|
+
|
|
64
|
+
feature_parents = parents[1..].compact
|
|
65
|
+
feature_parents.each do |feature_parent|
|
|
66
|
+
feature_commits = find_feature_commits(feature_parent, mainline_ancestors, commits)
|
|
67
|
+
feature_commits.each { |rev| merge_map[rev] = merge_rev }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
merge_map
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Walk backward from the feature branch tip, collecting commits that belong
|
|
75
|
+
# to this merge. Excludes any commits reachable from the mainline parent so
|
|
76
|
+
# that intermediate merges from main into the feature branch don't pull in
|
|
77
|
+
# unrelated mainline history.
|
|
78
|
+
#
|
|
79
|
+
# +mainline_ancestors+ is a pre-computed Set of commits reachable from the
|
|
80
|
+
# mainline parent, passed in to avoid redundant graph walks when a merge
|
|
81
|
+
# has multiple feature parents (octopus merges).
|
|
82
|
+
def find_feature_commits(start, mainline_ancestors, commits)
|
|
83
|
+
visited = Set.new
|
|
84
|
+
queue = [start]
|
|
85
|
+
head = 0
|
|
86
|
+
|
|
87
|
+
while head < queue.length
|
|
88
|
+
current = queue[head]
|
|
89
|
+
head += 1
|
|
90
|
+
next if mainline_ancestors.include?(current) || visited.include?(current) || !commits.key?(current)
|
|
91
|
+
|
|
92
|
+
visited << current
|
|
93
|
+
|
|
94
|
+
parents = commits[current][:parents]
|
|
95
|
+
parents&.each { |p| queue << p }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
visited
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Collect all ancestors reachable from a given commit (inclusive).
|
|
102
|
+
def collect_ancestors(start, commits)
|
|
103
|
+
ancestors = Set.new
|
|
104
|
+
queue = [start]
|
|
105
|
+
head = 0
|
|
106
|
+
|
|
107
|
+
while head < queue.length
|
|
108
|
+
current = queue[head]
|
|
109
|
+
head += 1
|
|
110
|
+
next if ancestors.include?(current) || !commits.key?(current)
|
|
111
|
+
|
|
112
|
+
ancestors << current
|
|
113
|
+
|
|
114
|
+
parents = commits[current][:parents]
|
|
115
|
+
parents&.each { |p| queue << p }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
ancestors
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Build a lookup from merge revision to its date, used to align
|
|
122
|
+
# rewritten feature-branch records with the merge commit's date.
|
|
123
|
+
def build_merge_dates(commits)
|
|
124
|
+
dates = {}
|
|
125
|
+
commits.each do |rev, info|
|
|
126
|
+
dates[rev] = info[:date] if info[:merge]
|
|
127
|
+
end
|
|
128
|
+
dates
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rewrite_records(records, merge_map, merge_dates)
|
|
132
|
+
records.map do |record|
|
|
133
|
+
merge_rev = merge_map[record.revision]
|
|
134
|
+
if merge_rev
|
|
135
|
+
ChangeRecord.new(
|
|
136
|
+
entity: record.entity,
|
|
137
|
+
author: record.author,
|
|
138
|
+
date: merge_dates[merge_rev] || record.date,
|
|
139
|
+
revision: merge_rev,
|
|
140
|
+
message: record.message,
|
|
141
|
+
loc_added: record.loc_added,
|
|
142
|
+
loc_deleted: record.loc_deleted,
|
|
143
|
+
parent_revisions: record.parent_revisions
|
|
144
|
+
)
|
|
145
|
+
else
|
|
146
|
+
record
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -5,18 +5,28 @@ module RubyMaat
|
|
|
5
5
|
module Parsers
|
|
6
6
|
# Git2 parser - preferred Git parser (more tolerant and faster)
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# Supports two formats:
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
# --
|
|
10
|
+
# Standard format (without parent info):
|
|
11
|
+
# git log --all --numstat --date=short --pretty=format:'--%h--%ad--%aN--%s' --no-renames
|
|
12
|
+
#
|
|
13
|
+
# Format with parent hashes (for --group-by-merge / PR-level coupling):
|
|
14
|
+
# git log --all --numstat --date=short --pretty=format:'--%h--%p--%ad--%aN--%s' --no-renames
|
|
15
|
+
# This format is generated by the "pr-coupling" preset.
|
|
16
|
+
#
|
|
17
|
+
# Sample standard format:
|
|
18
|
+
# --586b4eb--2015-06-15--Adam Tornhill--Add new feature
|
|
12
19
|
# 35 0 src/code_maat/mining/vcs.clj
|
|
13
|
-
# 2 1 test/file.rb
|
|
14
20
|
#
|
|
15
|
-
#
|
|
21
|
+
# Sample parent-hash format (merge commit has multiple parent hashes):
|
|
22
|
+
# --abc123--def456 ghi789--2015-06-16--Jane Doe--Merge pull request #42
|
|
16
23
|
# 10 5 lib/example.rb
|
|
17
24
|
class Git2Parser < BaseParser
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
# Format with parent hashes: --hash--parent1 parent2--date--author--message
|
|
26
|
+
COMMIT_WITH_PARENTS = /^--([a-z0-9]+)--([a-z0-9 ]*)--(\d{4}-\d{2}-\d{2})--([^\r\n]+?)--([^\r\n]*)$/
|
|
27
|
+
# Standard format: --hash--date--author--message
|
|
28
|
+
COMMIT_SEPARATOR = /^--([a-z0-9]+)--(\d{4}-\d{2}-\d{2})--([^\r\n]+?)--([^\r\n]*)$/
|
|
29
|
+
CHANGE_PATTERN = /^(-|\d+)[\t ]{1,10}(-|\d+)[\t ]{1,10}([^\r\n]*)$/
|
|
20
30
|
|
|
21
31
|
protected
|
|
22
32
|
|
|
@@ -28,11 +38,22 @@ module RubyMaat
|
|
|
28
38
|
line.strip!
|
|
29
39
|
next if line.empty?
|
|
30
40
|
|
|
31
|
-
if (commit_match = line.match(
|
|
41
|
+
if (commit_match = line.match(COMMIT_WITH_PARENTS))
|
|
42
|
+
parents_str = commit_match[2].strip
|
|
43
|
+
current_commit = {
|
|
44
|
+
revision: commit_match[1],
|
|
45
|
+
parent_revisions: parents_str.empty? ? [] : parents_str.split,
|
|
46
|
+
date: parse_date(commit_match[3]),
|
|
47
|
+
author: commit_match[4].strip,
|
|
48
|
+
message: commit_match[5].strip
|
|
49
|
+
}
|
|
50
|
+
elsif (commit_match = line.match(COMMIT_SEPARATOR))
|
|
32
51
|
current_commit = {
|
|
33
52
|
revision: commit_match[1],
|
|
53
|
+
parent_revisions: nil,
|
|
34
54
|
date: parse_date(commit_match[2]),
|
|
35
|
-
author: commit_match[3].strip
|
|
55
|
+
author: commit_match[3].strip,
|
|
56
|
+
message: commit_match[4].strip
|
|
36
57
|
}
|
|
37
58
|
elsif current_commit && (change_match = line.match(CHANGE_PATTERN))
|
|
38
59
|
added = clean_numstat(change_match[1])
|
|
@@ -47,8 +68,10 @@ module RubyMaat
|
|
|
47
68
|
author: current_commit[:author],
|
|
48
69
|
date: current_commit[:date],
|
|
49
70
|
revision: current_commit[:revision],
|
|
71
|
+
message: current_commit[:message],
|
|
50
72
|
loc_added: added,
|
|
51
|
-
loc_deleted: deleted
|
|
73
|
+
loc_deleted: deleted,
|
|
74
|
+
parent_revisions: current_commit[:parent_revisions]
|
|
52
75
|
)
|
|
53
76
|
end
|
|
54
77
|
end
|
|
@@ -14,8 +14,8 @@ module RubyMaat
|
|
|
14
14
|
# [abc123] Jane Doe 2015-06-16 Fix bug in parser
|
|
15
15
|
# 10 5 lib/example.rb
|
|
16
16
|
class GitParser < BaseParser
|
|
17
|
-
COMMIT_PATTERN = /^\[([a-f0-9]+)\]
|
|
18
|
-
CHANGE_PATTERN = /^(\d+|-)\
|
|
17
|
+
COMMIT_PATTERN = /^\[([a-f0-9]+)\] ([^0-9][^\t]*?) (\d{4}-\d{2}-\d{2}) ([^\r\n]*)$/
|
|
18
|
+
CHANGE_PATTERN = /^(\d+|-)\t(\d+|-)\t([^\r\n]+)$/
|
|
19
19
|
|
|
20
20
|
protected
|
|
21
21
|
|
|
@@ -14,7 +14,7 @@ module RubyMaat
|
|
|
14
14
|
# rev: 124 author: Jane Smith date: 2015-06-16 files:
|
|
15
15
|
# lib/helper.py
|
|
16
16
|
class MercurialParser < BaseParser
|
|
17
|
-
ENTRY_PATTERN = /^rev
|
|
17
|
+
ENTRY_PATTERN = /^rev: (\d+) author: ([^0-9][^:]*?) date: (\d{4}-\d{2}-\d{2}) files:$/
|
|
18
18
|
|
|
19
19
|
protected
|
|
20
20
|
|
|
@@ -16,8 +16,8 @@ module RubyMaat
|
|
|
16
16
|
# ... //depot/project/src/main.java#2 edit
|
|
17
17
|
# ... //depot/project/test/test.java#1 add
|
|
18
18
|
class PerforceParser < BaseParser
|
|
19
|
-
CHANGE_PATTERN = %r{^Change
|
|
20
|
-
FILE_PATTERN =
|
|
19
|
+
CHANGE_PATTERN = %r{^Change (\d+) by ([^@]*?)@\S+ on (\d{4}/\d{2}/\d{2})}
|
|
20
|
+
FILE_PATTERN = /^\.\.\. ([^#]*?)#\d+ (\w+)/
|
|
21
21
|
|
|
22
22
|
protected
|
|
23
23
|
|
|
@@ -19,9 +19,9 @@ module RubyMaat
|
|
|
19
19
|
# add $/Project/test/test.cs
|
|
20
20
|
class TfsParser < BaseParser
|
|
21
21
|
CHANGESET_PATTERN = /^Changeset:\s+(\d+)/
|
|
22
|
-
USER_PATTERN = /^User:\s+(
|
|
23
|
-
DATE_PATTERN = /^Date:\s+(
|
|
24
|
-
ITEM_PATTERN = /^\s+(edit|add|delete)\s+(\S
|
|
22
|
+
USER_PATTERN = /^User:\s+([^\r\n]+)/
|
|
23
|
+
DATE_PATTERN = /^Date:\s+([^\r\n]+)/
|
|
24
|
+
ITEM_PATTERN = /^\s+(edit|add|delete)\s+(\S[^\r\n]*)/
|
|
25
25
|
|
|
26
26
|
protected
|
|
27
27
|
|