git-commit-mailer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,291 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (C) 2009 Ryo Onodera <onodera@clear-code.com>
4
+ # Copyright (C) 2012-2014 Kouhei Sutou <kou@clear-code.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+
19
+ class GitCommitMailer
20
+ class CommitInfo < Info
21
+ class << self
22
+ def unescape_file_path(file_path)
23
+ if file_path =~ /\A"(.*)"\z/
24
+ escaped_file_path = $1
25
+ if escaped_file_path.respond_to?(:encoding)
26
+ encoding = escaped_file_path.encoding
27
+ else
28
+ encoding = nil
29
+ end
30
+ unescaped_file_path = escaped_file_path.gsub(/\\\\/, '\\').
31
+ gsub(/\\\"/, '"').
32
+ gsub(/\\([0-9]{1,3})/) do
33
+ $1.to_i(8).chr
34
+ end
35
+ unescaped_file_path.force_encoding(encoding) if encoding
36
+ unescaped_file_path
37
+ else
38
+ file_path
39
+ end
40
+ end
41
+ end
42
+
43
+ attr_reader :mailer, :revision, :reference
44
+ attr_reader :added_files, :copied_files, :deleted_files, :updated_files
45
+ attr_reader :renamed_files, :type_changed_files, :diffs
46
+ attr_reader :subject, :author_name, :author_email, :date, :summary
47
+ attr_accessor :merge_status
48
+ attr_writer :reference
49
+ attr_reader :merge_revisions
50
+ def initialize(mailer, reference, revision)
51
+ @mailer = mailer
52
+ @reference = reference
53
+ @revision = revision
54
+
55
+ @files = []
56
+ @added_files = []
57
+ @copied_files = []
58
+ @deleted_files = []
59
+ @updated_files = []
60
+ @renamed_files = []
61
+ @type_changed_files = []
62
+
63
+ set_records
64
+ parse_file_status
65
+ parse_diff
66
+
67
+ @merge_status = []
68
+ @merge_revisions = []
69
+ end
70
+
71
+ def first_parent
72
+ return nil if @parent_revisions.length.zero?
73
+
74
+ @parent_revisions[0]
75
+ end
76
+
77
+ def other_parents
78
+ return [] if @parent_revisions.length.zero?
79
+
80
+ @parent_revisions[1..-1]
81
+ end
82
+
83
+ def merge?
84
+ @parent_revisions.length >= 2
85
+ end
86
+
87
+ def message_id
88
+ "<#{@revision}@#{self.class.host_name}>"
89
+ end
90
+
91
+ def headers
92
+ [
93
+ "X-Git-Author: #{@author_name}",
94
+ "X-Git-Revision: #{@revision}",
95
+ # "X-Git-Repository: #{path}",
96
+ "X-Git-Repository: XXX",
97
+ "X-Git-Commit-Id: #{@revision}",
98
+ "Message-ID: #{message_id}",
99
+ *related_mail_headers
100
+ ]
101
+ end
102
+
103
+ def related_mail_headers
104
+ headers = []
105
+ @merge_revisions.each do |merge_revision|
106
+ merge_message_id = "<#{merge_revision}@#{self.class.host_name}>"
107
+ headers << "References: #{merge_message_id}"
108
+ headers << "In-Reply-To: #{merge_message_id}"
109
+ end
110
+ headers
111
+ end
112
+
113
+ def format_mail_subject
114
+ affected_path_info = ""
115
+ if @mailer.show_path?
116
+ _affected_paths = affected_paths
117
+ unless _affected_paths.empty?
118
+ affected_path_info = " (#{_affected_paths.join(',')})"
119
+ end
120
+ end
121
+
122
+ "[#{short_reference}#{affected_path_info}] " + subject
123
+ end
124
+
125
+ def format_mail_body_text
126
+ TextMailBodyFormatter.new(self).format
127
+ end
128
+
129
+ def format_mail_body_html
130
+ HTMLMailBodyFormatter.new(self).format
131
+ end
132
+
133
+ def short_revision
134
+ GitCommitMailer.short_revision(@revision)
135
+ end
136
+
137
+ def file_index(name)
138
+ @files.index(name)
139
+ end
140
+
141
+ def rss_title
142
+ format_mail_subject
143
+ end
144
+
145
+ def rss_content
146
+ "<pre>#{ERB::Util.h(format_mail_body_text)}</pre>"
147
+ end
148
+
149
+ private
150
+ def sub_paths(prefix)
151
+ prefixes = prefix.split(/\/+/)
152
+ results = []
153
+ @diffs.each do |diff|
154
+ paths = diff.file_path.split(/\/+/)
155
+ if prefixes.size < paths.size and prefixes == paths[0, prefixes.size]
156
+ results << paths[prefixes.size]
157
+ end
158
+ end
159
+ results
160
+ end
161
+
162
+ def affected_paths
163
+ paths = []
164
+ sub_paths = sub_paths('')
165
+ paths.concat(sub_paths)
166
+ paths.uniq
167
+ end
168
+
169
+ def set_records
170
+ author_name, author_email, date, subject, parent_revisions =
171
+ get_records(["%an", "%ae", "%at", "%s", "%P"])
172
+ @author_name = author_name
173
+ @author_email = author_email
174
+ @date = Time.at(date.to_i)
175
+ @subject = subject
176
+ @parent_revisions = parent_revisions.split
177
+ @summary = git("log -n 1 --pretty=format:%s%n%n%b #{@revision}")
178
+ end
179
+
180
+ def parse_diff
181
+ @diffs = []
182
+ output = []
183
+ n_bytes = 0
184
+ git("log -n 1 --pretty=format:'' -C -p #{@revision}") do |io|
185
+ io.each_line do |line|
186
+ n_bytes += line.bytesize
187
+ break if n_bytes > mailer.max_diff_size
188
+ utf8_line = force_utf8(line) || "(binary line)\n"
189
+ output << utf8_line
190
+ end
191
+ end
192
+ return if output.empty?
193
+
194
+ output.shift if output.first.strip.empty?
195
+
196
+ lines = []
197
+
198
+ line = output.shift
199
+ lines << line.chomp if line # take out the very first 'diff --git' header
200
+ while line = output.shift
201
+ line.chomp!
202
+ case line
203
+ when /\Adiff --git/
204
+ @diffs << create_file_diff(lines)
205
+ lines = [line]
206
+ else
207
+ lines << line
208
+ end
209
+ end
210
+
211
+ # create the last diff terminated by the EOF
212
+ @diffs << create_file_diff(lines) if lines.length > 0
213
+ end
214
+
215
+ def create_file_diff(lines)
216
+ diff = FileDiff.new(@mailer, lines, @revision)
217
+ diff.index = @files.index(diff.file_path)
218
+ diff
219
+ end
220
+
221
+ def parse_file_status
222
+ git("log -n 1 --pretty=format:'' -C --name-status #{@revision}").
223
+ lines.each do |line|
224
+ line.rstrip!
225
+ next if line.empty?
226
+ case line
227
+ when /\A([^\t]*?)\t([^\t]*?)\z/
228
+ status = $1
229
+ file = CommitInfo.unescape_file_path($2)
230
+
231
+ case status
232
+ when /^A/ # Added
233
+ @added_files << file
234
+ when /^M/ # Modified
235
+ @updated_files << file
236
+ when /^D/ # Deleted
237
+ @deleted_files << file
238
+ when /^T/ # File Type Changed
239
+ @type_changed_files << file
240
+ else
241
+ raise "unsupported status type: #{line.inspect}"
242
+ end
243
+
244
+ @files << file
245
+ when /\A([^\t]*?)\t([^\t]*?)\t([^\t]*?)\z/
246
+ status = $1
247
+ from_file = CommitInfo.unescape_file_path($2)
248
+ to_file = CommitInfo.unescape_file_path($3)
249
+
250
+ case status
251
+ when /^R/ # Renamed
252
+ @renamed_files << [from_file, to_file]
253
+ when /^C/ # Copied
254
+ @copied_files << [from_file, to_file]
255
+ else
256
+ raise "unsupported status type: #{line.inspect}"
257
+ end
258
+
259
+ @files << to_file
260
+ else
261
+ raise "unsupported status type: #{line.inspect}"
262
+ end
263
+ end
264
+ end
265
+
266
+ def force_utf8(string)
267
+ string.force_encoding("UTF-8")
268
+ return string if string.valid_encoding?
269
+
270
+ guess_encodings = [
271
+ "Windows-31J",
272
+ "EUC-JP",
273
+ ]
274
+ guess_encodings.each do |guess_encoding|
275
+ string.force_encoding(guess_encoding)
276
+ next unless string.valid_encoding?
277
+ begin
278
+ return string.encode("UTF-8")
279
+ rescue EncodingError
280
+ end
281
+ end
282
+
283
+ nil
284
+ end
285
+ end
286
+ end
287
+
288
+ require "git-commit-mailer/file-diff"
289
+ require "git-commit-mailer/mail-body-formatter"
290
+ require "git-commit-mailer/text-mail-body-formatter"
291
+ require "git-commit-mailer/html-mail-body-formatter"
@@ -0,0 +1,356 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (C) 2009 Ryo Onodera <onodera@clear-code.com>
4
+ # Copyright (C) 2012-2014 Kouhei Sutou <kou@clear-code.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+
19
+ class GitCommitMailer
20
+ class FileDiff
21
+ CHANGED_TYPE = {
22
+ :added => "Added",
23
+ :modified => "Modified",
24
+ :deleted => "Deleted",
25
+ :copied => "Copied",
26
+ :renamed => "Renamed",
27
+ }
28
+
29
+ attr_reader :changes
30
+ attr_accessor :index
31
+ def initialize(mailer, lines, revision)
32
+ @mailer = mailer
33
+ @index = nil
34
+ @body = ''
35
+ @changes = []
36
+
37
+ @type = :modified
38
+ @is_binary = false
39
+ @is_mode_changed = false
40
+
41
+ @old_blob = @new_blob = nil
42
+
43
+ parse_header(lines)
44
+ detect_metadata(revision)
45
+ parse_extended_headers(lines)
46
+ parse_body(lines)
47
+ end
48
+
49
+ def file_path
50
+ @to_file
51
+ end
52
+
53
+ def format_header
54
+ header = " #{CHANGED_TYPE[@type]}: #{@to_file} "
55
+ header << "(+#{@added_line} -#{@deleted_line})"
56
+ header << "#{format_file_mode}#{format_similarity_index}\n"
57
+ header << " Mode: #{@old_mode} -> #{@new_mode}\n" if @is_mode_changed
58
+ header << diff_separator
59
+ header
60
+ end
61
+
62
+ def format
63
+ formatted_diff = format_header
64
+
65
+ if @mailer.add_diff?
66
+ formatted_diff << headers + @body
67
+ else
68
+ formatted_diff << git_command
69
+ end
70
+
71
+ formatted_diff
72
+ end
73
+
74
+ private
75
+ def extract_file_path(file_path)
76
+ case CommitInfo.unescape_file_path(file_path)
77
+ when /\A[ab]\/(.*)\z/
78
+ $1
79
+ else
80
+ raise "unknown file path format: #{@to_file}"
81
+ end
82
+ end
83
+
84
+ def parse_header(lines)
85
+ line = lines.shift.strip
86
+ if line =~ /\Adiff --git ("?a\/.*) ("?b\/.*)/
87
+ @from_file = extract_file_path($1)
88
+ @to_file = extract_file_path($2)
89
+ else
90
+ raise "Unexpected diff header format: #{line}"
91
+ end
92
+ end
93
+
94
+ def detect_metadata(revision)
95
+ @new_revision = revision
96
+ @new_date = Time.at(@mailer.get_record(@new_revision, "%at").to_i)
97
+
98
+ begin
99
+ @old_revision = @mailer.parent_commit(revision)
100
+ @old_date = Time.at(@mailer.get_record(@old_revision, "%at").to_i)
101
+ rescue NoParentCommit
102
+ @old_revision = '0' * 40
103
+ @old_date = nil
104
+ end
105
+ # @old_revision = @mailer.parent_commit(revision)
106
+ end
107
+
108
+ def parse_ordinary_change(line)
109
+ case line
110
+ when /\A--- (a\/.*|"a\/.*"|\/dev\/null)\z/
111
+ @minus_file = CommitInfo.unescape_file_path($1)
112
+ @type = :added if $1 == '/dev/null'
113
+ when /\A\+\+\+ (b\/.*|"b\/.*"|\/dev\/null)\z/
114
+ @plus_file = CommitInfo.unescape_file_path($1)
115
+ @type = :deleted if $1 == '/dev/null'
116
+ when /\Aindex ([0-9a-f]{7,})\.\.([0-9a-f]{7,})/
117
+ @old_blob = $1
118
+ @new_blob = $2
119
+ else
120
+ return false
121
+ end
122
+ true
123
+ end
124
+
125
+ def parse_add_and_remove(line)
126
+ case line
127
+ when /\Anew file mode (.*)\z/
128
+ @type = :added
129
+ @new_file_mode = $1
130
+ when /\Adeleted file mode (.*)\z/
131
+ @type = :deleted
132
+ @deleted_file_mode = $1
133
+ else
134
+ return false
135
+ end
136
+ true
137
+ end
138
+
139
+ def parse_copy_and_rename(line)
140
+ case line
141
+ when /\Arename (from|to) (.*)\z/
142
+ @type = :renamed
143
+ when /\Acopy (from|to) (.*)\z/
144
+ @type = :copied
145
+ when /\Asimilarity index (.*)%\z/
146
+ @similarity_index = $1.to_i
147
+ else
148
+ return false
149
+ end
150
+ true
151
+ end
152
+
153
+ def parse_binary_file_change(line)
154
+ if line =~ /\ABinary files (.*) and (.*) differ\z/
155
+ @is_binary = true
156
+ if $1 == '/dev/null'
157
+ @type = :added
158
+ elsif $2 == '/dev/null'
159
+ @type = :deleted
160
+ else
161
+ @type = :modified
162
+ end
163
+ true
164
+ else
165
+ false
166
+ end
167
+ end
168
+
169
+ def parse_mode_change(line)
170
+ case line
171
+ when /\Aold mode (.*)\z/
172
+ @old_mode = $1
173
+ @is_mode_changed = true
174
+ when /\Anew mode (.*)\z/
175
+ @new_mode = $1
176
+ @is_mode_changed = true
177
+ else
178
+ return false
179
+ end
180
+ true
181
+ end
182
+
183
+ def parse_extended_headers(lines)
184
+ line = lines.shift
185
+ while line != nil and not line =~ /\A@@/
186
+ is_parsed = false
187
+ is_parsed ||= parse_ordinary_change(line)
188
+ is_parsed ||= parse_add_and_remove(line)
189
+ is_parsed ||= parse_copy_and_rename(line)
190
+ is_parsed ||= parse_binary_file_change(line)
191
+ is_parsed ||= parse_mode_change(line)
192
+ unless is_parsed
193
+ raise "unexpected extended line header: " + line
194
+ end
195
+
196
+ line = lines.shift
197
+ end
198
+ lines.unshift(line) if line
199
+ end
200
+
201
+ def parse_body(lines)
202
+ @added_line = @deleted_line = 0
203
+ from_offset = 0
204
+ to_offset = 0
205
+ lines.each do |line|
206
+ case line
207
+ when /\A@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)?/
208
+ from_offset = $1.to_i
209
+ to_offset = $2.to_i
210
+ @changes << [:hunk_header, [from_offset, to_offset], line]
211
+ when /\A\+/
212
+ @added_line += 1
213
+ @changes << [:added, to_offset, line]
214
+ to_offset += 1
215
+ when /\A\-/
216
+ @deleted_line += 1
217
+ @changes << [:deleted, from_offset, line]
218
+ from_offset += 1
219
+ else
220
+ @changes << [:not_changed, [from_offset, to_offset], line]
221
+ from_offset += 1
222
+ to_offset += 1
223
+ end
224
+
225
+ @body << line + "\n"
226
+ end
227
+ end
228
+
229
+ def format_date(date)
230
+ date.strftime('%Y-%m-%d %X %z')
231
+ end
232
+
233
+ def format_old_date
234
+ format_date(@old_date)
235
+ end
236
+
237
+ def format_new_date
238
+ format_date(@new_date)
239
+ end
240
+
241
+ def short_old_revision
242
+ GitCommitMailer.short_revision(@old_revision)
243
+ end
244
+
245
+ def short_new_revision
246
+ GitCommitMailer.short_revision(@new_revision)
247
+ end
248
+
249
+ def format_blob(blob)
250
+ if blob
251
+ " (#{blob})"
252
+ else
253
+ ""
254
+ end
255
+ end
256
+
257
+ def format_new_blob
258
+ format_blob(@new_blob)
259
+ end
260
+
261
+ def format_old_blob
262
+ format_blob(@old_blob)
263
+ end
264
+
265
+ def format_old_date_and_blob
266
+ format_old_date + format_old_blob
267
+ end
268
+
269
+ def format_new_date_and_blob
270
+ format_new_date + format_new_blob
271
+ end
272
+
273
+ def from_header
274
+ "--- #{@from_file} #{format_old_date_and_blob}\n"
275
+ end
276
+
277
+ def to_header
278
+ "+++ #{@to_file} #{format_new_date_and_blob}\n"
279
+ end
280
+
281
+ def headers
282
+ if @is_binary
283
+ "(Binary files differ)\n"
284
+ else
285
+ if (@type == :renamed || @type == :copied) && @similarity_index == 100
286
+ return ""
287
+ end
288
+
289
+ case @type
290
+ when :added
291
+ "--- /dev/null\n" + to_header
292
+ when :deleted
293
+ from_header + "+++ /dev/null\n"
294
+ else
295
+ from_header + to_header
296
+ end
297
+ end
298
+ end
299
+
300
+ def git_command
301
+ case @type
302
+ when :added
303
+ command = "show"
304
+ args = ["#{short_new_revision}:#{@to_file}"]
305
+ when :deleted
306
+ command = "show"
307
+ args = ["#{short_old_revision}:#{@to_file}"]
308
+ when :modified
309
+ command = "diff"
310
+ args = [short_old_revision, short_new_revision, "--", @to_file]
311
+ when :renamed
312
+ command = "diff"
313
+ args = [
314
+ "-C", "--diff-filter=R",
315
+ short_old_revision, short_new_revision, "--",
316
+ @from_file, @to_file,
317
+ ]
318
+ when :copied
319
+ command = "diff"
320
+ args = [
321
+ "-C", "--diff-filter=C",
322
+ short_old_revision, short_new_revision, "--",
323
+ @from_file, @to_file,
324
+ ]
325
+ else
326
+ raise "unknown diff type: #{@type}"
327
+ end
328
+
329
+ command += " #{args.join(' ')}" unless args.empty?
330
+ " % git #{command}\n"
331
+ end
332
+
333
+ def format_file_mode
334
+ case @type
335
+ when :added
336
+ " #{@new_file_mode}"
337
+ when :deleted
338
+ " #{@deleted_file_mode}"
339
+ else
340
+ ""
341
+ end
342
+ end
343
+
344
+ def format_similarity_index
345
+ if @type == :renamed or @type == :copied
346
+ " #{@similarity_index}%"
347
+ else
348
+ ""
349
+ end
350
+ end
351
+
352
+ def diff_separator
353
+ "#{"=" * 67}\n"
354
+ end
355
+ end
356
+ end