##// END OF EJS Templates
scm: cvs: fix parsing revisions if author is not ASCII....
Toshi MARUYAMA -
r5335:bebf8247a7f8
parent child
Show More
@@ -1,293 +1,291
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'iconv'
19 19
20 20 class Changeset < ActiveRecord::Base
21 21 belongs_to :repository
22 22 belongs_to :user
23 23 has_many :changes, :dependent => :delete_all
24 24 has_and_belongs_to_many :issues
25 25
26 26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
27 27 :description => :long_comments,
28 28 :datetime => :committed_on,
29 29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}}
30 30
31 31 acts_as_searchable :columns => 'comments',
32 32 :include => {:repository => :project},
33 33 :project_key => "#{Repository.table_name}.project_id",
34 34 :date_column => 'committed_on'
35 35
36 36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
37 37 :author_key => :user_id,
38 38 :find_options => {:include => [:user, {:repository => :project}]}
39 39
40 40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
41 41 validates_uniqueness_of :revision, :scope => :repository_id
42 42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
43 43
44 44 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
45 45 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
46 46
47 47 def revision=(r)
48 48 write_attribute :revision, (r.nil? ? nil : r.to_s)
49 49 end
50 50
51 51 # Returns the identifier of this changeset; depending on repository backends
52 52 def identifier
53 53 if repository.class.respond_to? :changeset_identifier
54 54 repository.class.changeset_identifier self
55 55 else
56 56 revision.to_s
57 57 end
58 58 end
59 59
60 60 def committed_on=(date)
61 61 self.commit_date = date
62 62 super
63 63 end
64 64
65 65 # Returns the readable identifier
66 66 def format_identifier
67 67 if repository.class.respond_to? :format_changeset_identifier
68 68 repository.class.format_changeset_identifier self
69 69 else
70 70 identifier
71 71 end
72 72 end
73 73
74 74 def project
75 75 repository.project
76 76 end
77 77
78 78 def author
79 79 user || committer.to_s.split('<').first
80 80 end
81 81
82 82 def before_create
83 83 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
84 84 self.comments = self.class.normalize_comments(
85 85 self.comments, repository.repo_log_encoding)
86 86 self.user = repository.find_committer_user(self.committer)
87 87 end
88 88
89 89 def after_create
90 90 scan_comment_for_issue_ids
91 91 end
92 92
93 93 TIMELOG_RE = /
94 94 (
95 95 ((\d+)(h|hours?))((\d+)(m|min)?)?
96 96 |
97 97 ((\d+)(h|hours?|m|min))
98 98 |
99 99 (\d+):(\d+)
100 100 |
101 101 (\d+([\.,]\d+)?)h?
102 102 )
103 103 /x
104 104
105 105 def scan_comment_for_issue_ids
106 106 return if comments.blank?
107 107 # keywords used to reference issues
108 108 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
109 109 ref_keywords_any = ref_keywords.delete('*')
110 110 # keywords used to fix issues
111 111 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
112 112
113 113 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
114 114
115 115 referenced_issues = []
116 116
117 117 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
118 118 action, refs = match[2], match[3]
119 119 next unless action.present? || ref_keywords_any
120 120
121 121 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
122 122 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
123 123 if issue
124 124 referenced_issues << issue
125 125 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
126 126 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
127 127 end
128 128 end
129 129 end
130 130
131 131 referenced_issues.uniq!
132 132 self.issues = referenced_issues unless referenced_issues.empty?
133 133 end
134 134
135 135 def short_comments
136 136 @short_comments || split_comments.first
137 137 end
138 138
139 139 def long_comments
140 140 @long_comments || split_comments.last
141 141 end
142 142
143 143 def text_tag
144 144 if scmid?
145 145 "commit:#{scmid}"
146 146 else
147 147 "r#{revision}"
148 148 end
149 149 end
150 150
151 151 # Returns the previous changeset
152 152 def previous
153 153 @previous ||= Changeset.find(:first,
154 154 :conditions => ['id < ? AND repository_id = ?',
155 155 self.id, self.repository_id],
156 156 :order => 'id DESC')
157 157 end
158 158
159 159 # Returns the next changeset
160 160 def next
161 161 @next ||= Changeset.find(:first,
162 162 :conditions => ['id > ? AND repository_id = ?',
163 163 self.id, self.repository_id],
164 164 :order => 'id ASC')
165 165 end
166 166
167 167 # Creates a new Change from it's common parameters
168 168 def create_change(change)
169 169 Change.create(:changeset => self,
170 170 :action => change[:action],
171 171 :path => change[:path],
172 172 :from_path => change[:from_path],
173 173 :from_revision => change[:from_revision])
174 174 end
175 175
176 176 private
177 177
178 178 # Finds an issue that can be referenced by the commit message
179 179 # i.e. an issue that belong to the repository project, a subproject or a parent project
180 180 def find_referenced_issue_by_id(id)
181 181 return nil if id.blank?
182 182 issue = Issue.find_by_id(id.to_i, :include => :project)
183 183 if issue
184 184 unless issue.project &&
185 185 (project == issue.project || project.is_ancestor_of?(issue.project) ||
186 186 project.is_descendant_of?(issue.project))
187 187 issue = nil
188 188 end
189 189 end
190 190 issue
191 191 end
192 192
193 193 def fix_issue(issue)
194 194 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
195 195 if status.nil?
196 196 logger.warn("No status macthes commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
197 197 return issue
198 198 end
199 199
200 200 # the issue may have been updated by the closure of another one (eg. duplicate)
201 201 issue.reload
202 202 # don't change the status is the issue is closed
203 203 return if issue.status && issue.status.is_closed?
204 204
205 205 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
206 206 issue.status = status
207 207 unless Setting.commit_fix_done_ratio.blank?
208 208 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
209 209 end
210 210 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
211 211 { :changeset => self, :issue => issue })
212 212 unless issue.save
213 213 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
214 214 end
215 215 issue
216 216 end
217 217
218 218 def log_time(issue, hours)
219 219 time_entry = TimeEntry.new(
220 220 :user => user,
221 221 :hours => hours,
222 222 :issue => issue,
223 223 :spent_on => commit_date,
224 224 :comments => l(:text_time_logged_by_changeset, :value => text_tag,
225 225 :locale => Setting.default_language)
226 226 )
227 227 time_entry.activity = log_time_activity unless log_time_activity.nil?
228 228
229 229 unless time_entry.save
230 230 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
231 231 end
232 232 time_entry
233 233 end
234 234
235 235 def log_time_activity
236 236 if Setting.commit_logtime_activity_id.to_i > 0
237 237 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
238 238 end
239 239 end
240 240
241 241 def split_comments
242 242 comments =~ /\A(.+?)\r?\n(.*)$/m
243 243 @short_comments = $1 || comments
244 244 @long_comments = $2.to_s.strip
245 245 return @short_comments, @long_comments
246 246 end
247 247
248 248 public
249 249
250 250 # Strips and reencodes a commit log before insertion into the database
251 251 def self.normalize_comments(str, encoding)
252 252 Changeset.to_utf8(str.to_s.strip, encoding)
253 253 end
254 254
255 private
256
257 255 def self.to_utf8(str, encoding)
258 256 return str if str.nil?
259 257 str.force_encoding("ASCII-8BIT") if str.respond_to?(:force_encoding)
260 258 if str.empty?
261 259 str.force_encoding("UTF-8") if str.respond_to?(:force_encoding)
262 260 return str
263 261 end
264 262 enc = encoding.blank? ? "UTF-8" : encoding
265 263 if str.respond_to?(:force_encoding)
266 264 if enc.upcase != "UTF-8"
267 265 str.force_encoding(enc)
268 266 str = str.encode("UTF-8", :invalid => :replace,
269 267 :undef => :replace, :replace => '?')
270 268 else
271 269 str.force_encoding("UTF-8")
272 270 if ! str.valid_encoding?
273 271 str = str.encode("US-ASCII", :invalid => :replace,
274 272 :undef => :replace, :replace => '?').encode("UTF-8")
275 273 end
276 274 end
277 275 else
278 276 ic = Iconv.new('UTF-8', enc)
279 277 txtar = ""
280 278 begin
281 279 txtar += ic.iconv(str)
282 280 rescue Iconv::IllegalSequence
283 281 txtar += $!.success
284 282 str = '?' + $!.failed[1,$!.failed.length]
285 283 retry
286 284 rescue
287 285 txtar += $!.success
288 286 end
289 287 str = txtar
290 288 end
291 289 str
292 290 end
293 291 end
@@ -1,203 +1,204
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'redmine/scm/adapters/cvs_adapter'
19 19 require 'digest/sha1'
20 20
21 21 class Repository::Cvs < Repository
22 22 validates_presence_of :url, :root_url, :log_encoding
23 23
24 24 ATTRIBUTE_KEY_NAMES = {
25 25 "url" => "CVSROOT",
26 26 "root_url" => "Module",
27 27 "log_encoding" => "Commit messages encoding",
28 28 }
29 29 def self.human_attribute_name(attribute_key_name)
30 30 ATTRIBUTE_KEY_NAMES[attribute_key_name] || super
31 31 end
32 32
33 33 def self.scm_adapter_class
34 34 Redmine::Scm::Adapters::CvsAdapter
35 35 end
36 36
37 37 def self.scm_name
38 38 'CVS'
39 39 end
40 40
41 41 def entry(path=nil, identifier=nil)
42 42 rev = identifier.nil? ? nil : changesets.find_by_revision(identifier)
43 43 scm.entry(path, rev.nil? ? nil : rev.committed_on)
44 44 end
45 45
46 46 def entries(path=nil, identifier=nil)
47 47 rev = nil
48 48 if ! identifier.nil?
49 49 rev = changesets.find_by_revision(identifier)
50 50 return nil if rev.nil?
51 51 end
52 52 entries = scm.entries(path, rev.nil? ? nil : rev.committed_on)
53 53 if entries
54 54 entries.each() do |entry|
55 55 if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? )
56 56 change=changes.find_by_revision_and_path(
57 57 entry.lastrev.revision,
58 58 scm.with_leading_slash(entry.path) )
59 59 if change
60 60 entry.lastrev.identifier = change.changeset.revision
61 61 entry.lastrev.revision = change.changeset.revision
62 62 entry.lastrev.author = change.changeset.committer
63 63 # entry.lastrev.branch = change.branch
64 64 end
65 65 end
66 66 end
67 67 end
68 68 entries
69 69 end
70 70
71 71 def cat(path, identifier=nil)
72 72 rev = nil
73 73 if ! identifier.nil?
74 74 rev = changesets.find_by_revision(identifier)
75 75 return nil if rev.nil?
76 76 end
77 77 scm.cat(path, rev.nil? ? nil : rev.committed_on)
78 78 end
79 79
80 80 def annotate(path, identifier=nil)
81 81 rev = nil
82 82 if ! identifier.nil?
83 83 rev = changesets.find_by_revision(identifier)
84 84 return nil if rev.nil?
85 85 end
86 86 scm.annotate(path, rev.nil? ? nil : rev.committed_on)
87 87 end
88 88
89 89 def diff(path, rev, rev_to)
90 90 # convert rev to revision. CVS can't handle changesets here
91 91 diff=[]
92 92 changeset_from = changesets.find_by_revision(rev)
93 93 if rev_to.to_i > 0
94 94 changeset_to = changesets.find_by_revision(rev_to)
95 95 end
96 96 changeset_from.changes.each() do |change_from|
97 97 revision_from = nil
98 98 revision_to = nil
99 99 if path.nil? || (change_from.path.starts_with? scm.with_leading_slash(path))
100 100 revision_from = change_from.revision
101 101 end
102 102 if revision_from
103 103 if changeset_to
104 104 changeset_to.changes.each() do |change_to|
105 105 revision_to=change_to.revision if change_to.path==change_from.path
106 106 end
107 107 end
108 108 unless revision_to
109 109 revision_to=scm.get_previous_revision(revision_from)
110 110 end
111 111 file_diff = scm.diff(change_from.path, revision_from, revision_to)
112 112 diff = diff + file_diff unless file_diff.nil?
113 113 end
114 114 end
115 115 return diff
116 116 end
117 117
118 118 def fetch_changesets
119 119 # some nifty bits to introduce a commit-id with cvs
120 120 # natively cvs doesn't provide any kind of changesets,
121 121 # there is only a revision per file.
122 122 # we now take a guess using the author, the commitlog and the commit-date.
123 123
124 124 # last one is the next step to take. the commit-date is not equal for all
125 125 # commits in one changeset. cvs update the commit-date when the *,v file was touched. so
126 126 # we use a small delta here, to merge all changes belonging to _one_ changeset
127 127 time_delta = 10.seconds
128 128 fetch_since = latest_changeset ? latest_changeset.committed_on : nil
129 129 transaction do
130 130 tmp_rev_num = 1
131 131 scm.revisions('', fetch_since, nil, :with_paths => true) do |revision|
132 132 # only add the change to the database, if it doen't exists. the cvs log
133 133 # is not exclusive at all.
134 134 tmp_time = revision.time.clone
135 135 unless changes.find_by_path_and_revision(
136 136 scm.with_leading_slash(revision.paths[0][:path]),
137 137 revision.paths[0][:revision]
138 138 )
139 139 cmt = Changeset.normalize_comments(revision.message, repo_log_encoding)
140 author_utf8 = Changeset.to_utf8(revision.author, repo_log_encoding)
140 141 cs = changesets.find(
141 142 :first,
142 143 :conditions => {
143 144 :committed_on => tmp_time - time_delta .. tmp_time + time_delta,
144 :committer => revision.author,
145 :committer => author_utf8,
145 146 :comments => cmt
146 147 }
147 148 )
148 149 # create a new changeset....
149 150 unless cs
150 151 # we use a temporaray revision number here (just for inserting)
151 152 # later on, we calculate a continous positive number
152 153 tmp_time2 = tmp_time.clone.gmtime
153 154 branch = revision.paths[0][:branch]
154 155 scmid = branch + "-" + tmp_time2.strftime("%Y%m%d-%H%M%S")
155 156 cs = Changeset.create(:repository => self,
156 157 :revision => "tmp#{tmp_rev_num}",
157 158 :scmid => scmid,
158 159 :committer => revision.author,
159 160 :committed_on => tmp_time,
160 161 :comments => revision.message)
161 162 tmp_rev_num += 1
162 163 end
163 164 # convert CVS-File-States to internal Action-abbrevations
164 165 # default action is (M)odified
165 166 action = "M"
166 167 if revision.paths[0][:action] == "Exp" && revision.paths[0][:revision] == "1.1"
167 168 action = "A" # add-action always at first revision (= 1.1)
168 169 elsif revision.paths[0][:action] == "dead"
169 170 action = "D" # dead-state is similar to Delete
170 171 end
171 172 Change.create(
172 173 :changeset => cs,
173 174 :action => action,
174 175 :path => scm.with_leading_slash(revision.paths[0][:path]),
175 176 :revision => revision.paths[0][:revision],
176 177 :branch => revision.paths[0][:branch]
177 178 )
178 179 end
179 180 end
180 181
181 182 # Renumber new changesets in chronological order
182 183 changesets.find(
183 184 :all,
184 185 :order => 'committed_on ASC, id ASC',
185 186 :conditions => "revision LIKE 'tmp%'"
186 187 ).each do |changeset|
187 188 changeset.update_attribute :revision, next_revision_number
188 189 end
189 190 end # transaction
190 191 @current_revision_number = nil
191 192 end
192 193
193 194 private
194 195
195 196 # Returns the next revision number to assign to a CVS changeset
196 197 def next_revision_number
197 198 # Need to retrieve existing revision numbers to sort them as integers
198 199 sql = "SELECT revision FROM #{Changeset.table_name} "
199 200 sql << "WHERE repository_id = #{id} AND revision NOT LIKE 'tmp%'"
200 201 @current_revision_number ||= (connection.select_values(sql).collect(&:to_i).max || 0)
201 202 @current_revision_number += 1
202 203 end
203 204 end
General Comments 0
You need to be logged in to leave comments. Login now