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.
@@ -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
- # Input: git log --all --numstat --date=short --pretty=format:'--%h--%ad--%aN' --no-renames --after=YYYY-MM-DD
8
+ # Supports two formats:
9
9
  #
10
- # Sample format:
11
- # --586b4eb--2015-06-15--Adam Tornhill
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
- # --abc123--2015-06-16--Jane Doe
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
- COMMIT_SEPARATOR = /^--([a-z0-9]+)--(\d{4}-\d{2}-\d{2})--(.+)$/
19
- CHANGE_PATTERN = /^(-|\d+)\s+(-|\d+)\s+(.*)$/
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(COMMIT_SEPARATOR))
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]+)\]\s+(.+?)\s+(\d{4}-\d{2}-\d{2})\s+(.*)$/
18
- CHANGE_PATTERN = /^(\d+|-)\s+(\d+|-)\s+(.+)$/
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:\s+(\d+)\s+author:\s+(.+?)\s+date:\s+(\d{4}-\d{2}-\d{2})\s+files:$/
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\s+(\d+)\s+by\s+([^@]+)@\S+\s+on\s+(\d{4}/\d{2}/\d{2})}
20
- FILE_PATTERN = /^\.\.\.\s+(.+?)#\d+\s+(\w+)/
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