git-commit-mailer 1.0.0

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,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