Skip unhandled commands correctly
[rcs-fast-export] / rcs-fast-export.rb
1 #!/usr/bin/ruby
2
3 =begin
4 TODO
5         * Option to coalesce commits that only differ by having a symbol or not
6         * Further coalescing options? (e.g. small logfile differences)
7         * Proper branching support in multi-file export
8         * Optimize memory usage by discarding unneeded text
9 =end
10
11 require 'pp'
12
13 # Integer#odd? was introduced in Ruby 1.8.7, backport it to
14 # older versions
15 unless 2.respond_to? :odd?
16         class Integer
17                 def odd?
18                         self % 2 == 1
19                 end
20         end
21 end
22
23 def usage
24         STDERR.puts <<EOM
25 #{$0} [options] file [file ...]
26
27 Fast-export the RCS history of one or more files. If a directory is specified,
28 all RCS-tracked files in the directory and its descendants are exported.
29
30 When importing single files, their pathname is discarded during import. When
31 importing directories, only the specified directory component is discarded.
32
33 When importing a single file, RCS commits are converted one by one. Otherwise,
34 some heuristics is used to determine how to coalesce commits of different.
35
36 Currently, commits are coalesced if they share the exact same log and symbols,
37 and if their date differs by no more than a the user-specified fuzziness.
38
39 Options:
40         --help, -h, -?          display this help text
41         --authors-file, -A      specify a file containing username = Full Name <email> mappings
42         --rcs-commit-fuzz       fuzziness in RCS commits to be considered a single one when
43                                 importing multiple files
44                                 (in seconds, defaults to 300, i.e. 5 minutes)
45         --[no-]tag-each-rev     [do not] create a lightweight tag for each RCS revision when
46                                 importing a single file
47         --[no-]log-filename     [do not] prepend the filename to the commit log when importing
48                                 a single file
49
50 Config options:
51         rcs.authorsFile         for --authors-file
52         rcs.tagEachRev          for --tag-each-rev
53         rcs.logFilename         for --log-filename
54         rcs.commitFuzz          for --rcs-commit-fuzz
55         rcs.tagFuzz             for --rcs-tag-fuzz
56
57 EOM
58 end
59
60 def not_found(arg)
61         STDERR.puts "Could not find #{arg}"
62 end
63
64 # returns a hash that maps usernames to author names & emails
65 def load_authors_file(fn)
66         hash = {}
67         begin
68                 File.open(File.expand_path(fn)) do |io|
69                         io.each_line do |line|
70                                 uname, author = line.split('=', 2)
71                                 uname.strip!
72                                 author.strip!
73                                 STDERR.puts "Username #{uname} redefined to #{author}" if hash.has_key? uname
74                                 hash[uname] = author
75                         end
76                 end
77         rescue
78                 not_found(fn)
79         end
80         return hash
81 end
82
83 class Time
84         def Time.rcs(string)
85                 fields = string.split('.')
86                 raise ArgumentError, "wrong number of fields for RCS date #{string}" unless fields.length == 6
87                 Time.utc(*fields)
88         end
89 end
90
91 module RCS
92         # strip an optional final ;
93         def RCS.clean(arg)
94                 arg.chomp(';')
95         end
96
97         # strip the first and last @, and de-double @@s
98         def RCS.sanitize(arg)
99                 case arg
100                 when Array
101                         ret = arg.dup
102                         raise 'malformed first line' unless ret.first[0,1] == '@'
103                         raise 'malformed last line' unless ret.last[-1,1] == '@'
104                         ret.first.sub!(/^@/,'')
105                         ret.last.sub!(/@$/,'')
106                         ret.map { |l| l.gsub('@@','@') }
107                 when String
108                         arg.chomp('@').sub(/^@/,'').gsub('@@','@')
109                 else
110                         raise
111                 end
112         end
113
114         # clean and sanitize
115         def RCS.at_clean(arg)
116                 RCS.sanitize RCS.clean(arg)
117         end
118
119         def RCS.mark(key)
120                 @@marks ||= {}
121                 if @@marks.key? key
122                         @@marks[key]
123                 else
124                         @@marks[key] = @@marks.length + 1
125                 end
126         end
127
128         def RCS.blob(file, rev)
129                 RCS.mark([file, rev])
130         end
131
132         def RCS.commit(commit)
133                 RCS.mark(commit)
134         end
135
136         class File
137                 attr_accessor :head, :comment, :desc, :revision, :fname, :mode
138                 def initialize(fname, executable)
139                         @fname = fname.dup
140                         @head = nil
141                         @comment = nil
142                         @desc = []
143                         @revision = Hash.new { |h, r| h[r] = Revision.new(self, r) }
144                         @mode = executable ? '755' : '644'
145                 end
146
147                 def has_revision?(rev)
148                         @revision.has_key?(rev) and not @revision[rev].author.nil?
149                 end
150
151                 def export_commits(opts={})
152                         counter = 0
153                         exported = []
154                         until @revision.empty?
155                                 counter += 1
156
157                                 # a string sort is a very good candidate for
158                                 # export order, getting a miss only for
159                                 # multi-digit revision components
160                                 keys = @revision.keys.sort
161
162                                 STDERR.puts "commit export loop ##{counter}"
163                                 STDERR.puts "\t#{exported.length} commits exported so far: #{exported.join(', ')}" unless exported.empty?
164                                 STDERR.puts "\t#{keys.size} to export: #{keys.join(', ')}"
165
166                                 keys.each do |key|
167                                         rev = @revision[key]
168                                         # the parent commit is rev.next if we're on the
169                                         # master branch (rev.branch is nil) or
170                                         # rev.diff_base otherwise
171                                         from = rev.branch.nil? ? rev.next : rev.diff_base
172                                         # A commit can only be exported if it has no
173                                         # parent, or if the parent has been exported
174                                         # already. Skip this commit otherwise
175                                         if from and not exported.include? from
176                                                 next
177                                         end
178
179                                         branch = rev.branch || 'master'
180                                         author = opts[:authors][rev.author] || "#{rev.author} <empty>"
181                                         date = "#{rev.date.tv_sec} +0000"
182                                         log = String.new
183                                         if opts[:log_filename]
184                                                 log << @fname << ": "
185                                         end
186                                         log << rev.log.to_s
187
188                                         puts "commit refs/heads/#{branch}"
189                                         puts "mark :#{RCS.commit key}"
190                                         puts "committer #{author} #{date}"
191                                         puts "data #{log.length}"
192                                         puts log unless log.empty?
193                                         puts "from :#{RCS.commit from}" if rev.branch_point
194                                         puts "M #{@mode} :#{RCS.blob @fname, key} #{@fname}"
195
196                                         # TODO FIXME this *should* be safe, in
197                                         # that it should not unduly move
198                                         # branches back in time, but I'm not
199                                         # 100% sure ...
200                                         rev.branches.each do |sym|
201                                                 puts "reset refs/heads/#{sym}"
202                                                 puts "from :#{RCS.commit key}"
203                                         end
204                                         rev.symbols.each do |sym|
205                                                 puts "reset refs/tags/#{sym}"
206                                                 puts "from :#{RCS.commit key}"
207                                         end
208                                         if opts[:tag_each_rev]
209                                                 puts "reset refs/tags/#{key}"
210                                                 puts "from :#{RCS.commit key}"
211                                         end
212
213                                         exported.push key
214                                 end
215                                 exported.each { |k| @revision.delete(k) }
216                         end
217                 end
218         end
219
220         class Revision
221                 attr_accessor :rev, :author, :state, :next
222                 attr_accessor :branches, :log, :text, :symbols
223                 attr_accessor :branch, :diff_base, :branch_point
224                 attr_reader   :date
225                 def initialize(file, rev)
226                         @file = file
227                         @rev = rev
228                         @author = nil
229                         @date = nil
230                         @state = nil
231                         @next = nil
232                         @branches = []
233                         @branch = nil
234                         @branch_point = nil
235                         @diff_base = nil
236                         @log = []
237                         @text = []
238                         @symbols = []
239                 end
240
241                 def date=(str)
242                         @date = Time.rcs(str)
243                 end
244
245                 def blob
246                         str = @text.join('')
247                         ret = "blob\nmark :#{RCS.blob @file.fname, @rev}\ndata #{str.length}\n#{str}\n"
248                         ret
249                 end
250         end
251
252         def RCS.parse(fname, rcsfile)
253                 rcs = RCS::File.new(fname, ::File.executable?(rcsfile))
254
255                 ::File.open(rcsfile, 'r') do |file|
256                         status = [:basic]
257                         rev = nil
258                         lines = []
259                         difflines = []
260                         file.each_line do |line|
261                                 case status.last
262                                 when :basic
263                                         command, args = line.split($;,2)
264                                         next if command.empty?
265
266                                         if command.chomp!(';')
267                                                 STDERR.puts "Skipping empty command #{command.inspect}" if $DEBUG
268                                                 next
269                                         end
270
271                                         case command
272                                         when 'head'
273                                                 rcs.head = RCS.clean(args.chomp)
274                                         when 'symbols'
275                                                 status.push :symbols
276                                                 next if args.empty?
277                                                 line = args; redo
278                                         when 'comment'
279                                                 rcs.comment = RCS.at_clean(args.chomp)
280                                         when /^[0-9.]+$/
281                                                 rev = command.dup
282                                                 if rcs.has_revision?(rev)
283                                                         status.push :revision_data
284                                                 else
285                                                         status.push :new_revision
286                                                 end
287                                         when 'desc'
288                                                 status.push :desc
289                                                 lines.clear
290                                                 status.push :read_lines
291                                         when 'branch', 'access', 'locks', 'expand'
292                                                 STDERR.puts "Skipping unhandled command #{command.inspect}" if $DEBUG
293                                                 status.push :skipping_lines
294                                                 next if args.empty?
295                                                 line = args; redo
296                                         else
297                                                 raise "Unknown command #{command.inspect}"
298                                         end
299                                 when :skipping_lines
300                                         status.pop if line.strip.chomp!(';')
301                                 when :symbols
302                                         # we can have multiple symbols per line
303                                         pairs = line.strip.split($;)
304                                         pairs.each do |pair|
305                                                 sym, rev = pair.strip.split(':',2);
306                                                 if rev
307                                                         status.pop if rev.chomp!(';')
308                                                         rcs.revision[rev].symbols << sym
309                                                 else
310                                                         status.pop
311                                                 end
312                                         end
313                                 when :desc
314                                         rcs.desc.replace lines.dup
315                                         status.pop
316                                 when :read_lines
317                                         # we sanitize lines as we read them
318
319                                         actual_line = line.dup
320
321                                         # the first line must begin with a @, which we strip
322                                         if lines.empty?
323                                                 ats = line.match(/^@+/)
324                                                 raise 'malformed line' unless ats
325                                                 actual_line.replace line.sub(/^@/,'')
326                                         end
327
328                                         # if the line ends with an ODD number of @, it's the
329                                         # last line -- we work on actual_line so that content
330                                         # such as @\n or @ work correctly (they would be
331                                         # encoded respectively as ['@@@\n','@\n'] and
332                                         # ['@@@@\n']
333                                         ats = actual_line.chomp.match(/@+$/)
334                                         if nomore = (ats && Regexp.last_match(0).length.odd?)
335                                                 actual_line.replace actual_line.chomp.sub(/@$/,'')
336                                         end
337                                         lines << actual_line.gsub('@@','@')
338                                         if nomore
339                                                 status.pop
340                                                 redo
341                                         end
342                                 when :new_revision
343                                         case line.chomp
344                                         when /^date\s+(\S+);\s+author\s+(\S+);\s+state\s+(\S+);$/
345                                                 rcs.revision[rev].date = $1
346                                                 rcs.revision[rev].author = $2
347                                                 rcs.revision[rev].state = $3
348                                         when 'branches'
349                                                 status.push :branches
350                                         when /branches\s*;/
351                                                 next
352                                         when /^next\s+(\S+)?;$/
353                                                 nxt = rcs.revision[rev].next = $1
354                                                 next unless nxt
355                                                 raise "multiple diff_bases for #{nxt}" unless rcs.revision[nxt].diff_base.nil?
356                                                 rcs.revision[nxt].diff_base = rev
357                                                 rcs.revision[nxt].branch = rcs.revision[rev].branch
358                                         else
359                                                 status.pop
360                                         end
361                                 when :branches
362                                         candidate = line.split(';',2)
363                                         branch = candidate.first.strip
364                                         rcs.revision[rev].branches.push branch
365                                         raise "multiple diff_bases for #{branch}" unless rcs.revision[branch].diff_base.nil?
366                                         rcs.revision[branch].diff_base = rev
367                                         # we drop the last number from the branch name
368                                         rcs.revision[branch].branch = branch.sub(/\.\d+$/,'.x')
369                                         rcs.revision[branch].branch_point = rev
370                                         status.pop if candidate.length > 1
371                                 when :revision_data
372                                         case line.chomp
373                                         when 'log'
374                                                 status.push :log
375                                                 lines.clear
376                                                 status.push :read_lines
377                                         when 'text'
378                                                 if rev == rcs.head
379                                                         status.push :head
380                                                 else
381                                                         status.push :diff
382                                                 end
383                                                 lines.clear
384                                                 status.push :read_lines
385                                         else
386                                                 status.pop
387                                         end
388                                 when :log
389                                         rcs.revision[rev].log.replace lines.dup
390                                         status.pop
391                                 when :head
392                                         rcs.revision[rev].text.replace lines.dup
393                                         puts rcs.revision[rev].blob
394                                         status.pop
395                                 when :diff
396                                         difflines.replace lines.dup
397                                         difflines.pop if difflines.last.empty?
398                                         base = rcs.revision[rev].diff_base
399                                         unless rcs.revision[base].text
400                                                 pp rcs
401                                                 puts rev, base
402                                                 raise 'no diff base!'
403                                         end
404                                         # deep copy
405                                         buffer = []
406                                         rcs.revision[base].text.each { |l| buffer << [l.dup] }
407
408                                         adding = false
409                                         index = nil
410                                         count = nil
411
412                                         while l = difflines.shift
413                                                 if adding
414                                                         raise 'negative index during insertion' if index < 0
415                                                         raise 'negative count during insertion' if count < 0
416                                                         adding << l
417                                                         count -= 1
418                                                         # collected all the lines, put the before
419                                                         unless count > 0
420                                                                 unless buffer[index]
421                                                                         buffer[index] = []
422                                                                 end
423                                                                 buffer[index].unshift(*adding)
424                                                                 adding = false
425                                                         end
426                                                         next
427                                                 end
428
429                                                 l.chomp!
430                                                 raise 'malformed diff' unless l =~ /^([ad])(\d+) (\d+)$/
431                                                 diff_cmd = $1.intern
432                                                 index = $2.to_i
433                                                 count = $3.to_i
434                                                 case diff_cmd
435                                                 when :d
436                                                         # for deletion, index 1 is the first index, so the Ruby
437                                                         # index is one less than the diff one
438                                                         index -= 1
439                                                         # we replace them with empty string so that 'a' commands
440                                                         # referring to the same line work properly
441                                                         while count > 0
442                                                                 buffer[index].clear
443                                                                 index += 1
444                                                                 count -= 1
445                                                         end
446                                                 when :a
447                                                         # addition will prepend the appropriate lines
448                                                         # to the given index, and in this case Ruby
449                                                         # and diff indices are the same
450                                                         adding = []
451                                                 end
452                                         end
453
454                                         # turn the buffer into an array of lines, deleting the empty ones
455                                         buffer.delete_if { |l| l.empty? }
456                                         buffer.flatten!
457
458                                         rcs.revision[rev].text = buffer
459                                         puts rcs.revision[rev].blob
460                                         status.pop
461                                 else
462                                         raise "Unknown status #{status.last}"
463                                 end
464                         end
465                 end
466
467                 # clean up the symbols/branches: look for revisions that have
468                 # one or more symbols but no dates, and make them into
469                 # branches, pointing to the highest commit with that key
470                 branches = []
471                 keys = rcs.revision.keys
472                 rcs.revision.each do |key, rev|
473                         if rev.date.nil? and not rev.symbols.empty?
474                                 top = keys.select { |k| k.match(/^#{key}\./) }.sort.last
475                                 tr = rcs.revision[top]
476                                 raise "unhandled complex branch structure met: #{rev.inspect} refers #{tr.inspect}" if tr.date.nil?
477                                 tr.branches |= rev.symbols
478                                 branches << key
479                         end
480                 end
481                 branches.each { |k| rcs.revision.delete k }
482
483                 return rcs
484         end
485
486         class Tree
487                 def initialize(commit)
488                         @commit = commit
489                         @files = Hash.new
490                 end
491
492                 def add(rcs, rev)
493                         if @files.key? rcs
494                                 prev = @files[rcs]
495                                 if prev.log == rev.log
496                                         str = "re-adding existing file #{rcs.fname} (old: #{prev.rev}, new: #{rev.rev})"
497                                 else
498                                         str = "re-adding existing file #{rcs.fname} (old: #{[prev.rev, prev.log.to_s].inspect}, new: #{[rev.rev, rev.log.to_s].inspect})"
499                                 end
500                                 if prev.text != rev.text
501                                         raise str
502                                 else
503                                         @commit.warn_about str
504                                 end
505                         end
506                         @files[rcs] = rev
507                 end
508
509                 def each &block
510                         @files.each &block
511                 end
512
513                 def to_a
514                         files = []
515                         @files.map do |rcs, rev|
516                                 files << "M #{rcs.mode} :#{RCS.blob rcs.fname, rev.rev} #{rcs.fname}"
517                         end
518                         files
519                 end
520
521                 def to_s
522                         self.to_a.join("\n")
523                 end
524         end
525
526         class Commit
527                 attr_accessor :date, :log, :symbols, :author, :branch
528                 attr_accessor :tree
529                 def initialize(rcs, rev)
530                         raise NotImplementedError if rev.branch
531                         self.date = rev.date.dup
532                         self.log = rev.log.dup
533                         self.symbols = rev.symbols.dup
534                         self.author = rev.author
535                         self.branch = rev.branch
536
537                         self.tree = Tree.new self
538                         self.tree.add rcs, rev
539                 end
540
541                 def to_a
542                         [self.date, self.branch, self.symbols, self.author, self.log, self.tree.to_a]
543                 end
544
545                 def warn_about(str)
546                         warn str + " for commit on #{self.date}"
547                 end
548
549                 # Sort by date and then by number of symbols
550                 def <=>(other)
551                         ds = self.date <=> other.date
552                         if ds != 0
553                                 return ds
554                         else
555                                 return self.symbols.length <=> other.symbols.length
556                         end
557                 end
558
559                 def merge!(commit)
560                         commit.tree.each do |rcs, rev|
561                                 self.tree.add rcs, rev
562                         end
563                         if commit.date > self.date
564                                 warn_about "updating date to #{commit.date}"
565                                 self.date = commit.date
566                         end
567                         # TODO this is a possible option when merging commits with differing symbols
568                         # self.symbols |= commit.symbols
569                 end
570
571                 def export(opts={})
572                         xbranch = self.branch || 'master'
573                         xauthor = opts[:authors][self.author] || "#{self.author} <empty>"
574                         xlog = self.log.to_s
575                         numdate = self.date.tv_sec
576                         xdate = "#{numdate} +0000"
577                         key = numdate.to_s
578
579                         puts "commit refs/heads/#{xbranch}"
580                         puts "mark :#{RCS.commit key}"
581                         puts "committer #{xauthor} #{xdate}"
582                         puts "data #{xlog.length}"
583                         puts xlog unless xlog.empty?
584                         # TODO branching support for multi-file export
585                         # puts "from :#{RCS.commit from}" if self.branch_point
586                         puts self.tree.to_s
587
588                         # TODO branching support for multi-file export
589                         # rev.branches.each do |sym|
590                         #       puts "reset refs/heads/#{sym}"
591                         #       puts "from :#{RCS.commit key}"
592                         # end
593
594                         self.symbols.each do |sym|
595                                 puts "reset refs/tags/#{sym}"
596                                 puts "from :#{RCS.commit key}"
597                         end
598
599                 end
600         end
601 end
602
603 require 'getoptlong'
604
605 opts = GetoptLong.new(
606         # Authors file, like git-svn and git-cvsimport, more than one can be
607         # specified
608         ['--authors-file', '-A', GetoptLong::REQUIRED_ARGUMENT],
609         # RCS file suffix, like RCS
610         ['--rcs-suffixes', '-x', GetoptLong::REQUIRED_ARGUMENT],
611         # Date fuzziness for commits to be considered the same (in seconds)
612         ['--rcs-commit-fuzz', GetoptLong::REQUIRED_ARGUMENT],
613         # tag each revision?
614         ['--tag-each-rev', GetoptLong::NO_ARGUMENT],
615         ['--no-tag-each-rev', GetoptLong::NO_ARGUMENT],
616         # prepend filenames to commit logs?
617         ['--log-filename', GetoptLong::NO_ARGUMENT],
618         ['--no-log-filename', GetoptLong::NO_ARGUMENT],
619         ['--help', '-h', '-?', GetoptLong::NO_ARGUMENT]
620 )
621
622 # We read options in order, but they apply to all passed parameters.
623 # TODO maybe they should only apply to the following, unless there's only one
624 # file?
625 opts.ordering = GetoptLong::RETURN_IN_ORDER
626
627 file_list = []
628 parse_options = {
629         :authors => Hash.new,
630         :commit_fuzz => 300,
631         :tag_fuzz => -1,
632 }
633
634 # Read config options
635 `git config --get-all rcs.authorsfile`.each_line do |fn|
636         parse_options[:authors].merge! load_authors_file(fn.chomp)
637 end
638
639 parse_options[:tag_each_rev] = (
640         `git config --bool rcs.tageachrev`.chomp == 'true'
641 ) ? true : false
642
643 parse_options[:log_filename] = (
644         `git config --bool rcs.logfilename`.chomp == 'true'
645 ) ? true : false
646
647 fuzz = `git config --int rcs.commitFuzz`.chomp
648 parse_options[:commit_fuzz] = fuzz.to_i unless fuzz.empty?
649
650 fuzz = `git config --int rcs.tagFuzz`.chomp
651 parse_options[:tag_fuzz] = fuzz.to_i unless fuzz.empty?
652
653 opts.each do |opt, arg|
654         case opt
655         when '--authors-file'
656                 authors = load_authors_file(arg)
657                 redef = parse_options[:authors].keys & authors.keys
658                 STDERR.puts "Authors file #{arg} redefines #{redef.join(', ')}" unless redef.empty?
659                 parse_options[:authors].merge!(authors)
660         when '--rcs-suffixes'
661                 # TODO
662         when '--rcs-commit-fuzz'
663                 parse_options[:commit_fuzz] = arg.to_i
664         when '--rcs-tag-fuzz'
665                 parse_options[:tag_fuzz] = arg.to_i
666         when '--tag-each-rev'
667                 parse_options[:tag_each_rev] = true
668         when '--no-tag-each-rev'
669                 # this is the default, which is fine since the missing key
670                 # (default) returns nil which is false in Ruby
671                 parse_options[:tag_each_rev] = false
672         when '--log-filename'
673                 parse_options[:log_filename] = true
674         when '--no-log-filename'
675                 # this is the default, which is fine since the missing key
676                 # (default) returns nil which is false in Ruby
677                 parse_options[:log_filename] = false
678         when ''
679                 file_list << arg
680         when '--help'
681                 usage
682                 exit
683         end
684 end
685
686 if parse_options[:tag_fuzz] < parse_options[:commit_fuzz]
687         parse_options[:tag_fuzz] = parse_options[:commit_fuzz]
688 end
689
690 require 'etc'
691
692 user = Etc.getlogin || ENV['USER']
693
694 # steal username/email data from other init files that may contain the
695 # information
696 def steal_username
697         [
698                 # the user's .hgrc file for a username field
699                 ['~/.hgrc',   /^\s*username\s*=\s*(["'])?(.*)\1$/,       2],
700                 # the user's .(g)vimrc for a changelog_username setting
701                 ['~/.vimrc',  /changelog_username\s*=\s*(["'])?(.*)\1$/, 2],
702                 ['~/.gvimrc', /changelog_username\s*=\s*(["'])?(.*)\1$/, 2],
703                 []
704         ].each do |fn, rx, idx|
705                 file = File.expand_path fn
706                 if File.readable?(file) and File.read(file) =~ rx
707                         parse_options[:authors][user] = Regexp.last_match(idx).strip
708                         break
709                 end
710         end
711 end
712
713 if user and not user.empty? and not parse_options[:authors].has_key?(user)
714         name = ENV['GIT_AUTHOR_NAME'] || ''
715         name.replace(`git config user.name`.chomp) if name.empty?
716         name.replace(Etc.getpwnam(user).gecos) if name.empty?
717
718         if name.empty?
719                 # couldn't find a name, try to steal data from other sources
720                 steal_username
721         else
722                 # if we found a name, try to find an email too
723                 email = ENV['GIT_AUTHOR_EMAIL'] || ''
724                 email.replace(`git config user.email`.chomp) if email.empty?
725
726                 if email.empty?
727                         # couldn't find an email, try to steal data too
728                         steal_username
729                 else
730                         # we got both a name and email, fill the info
731                         parse_options[:authors][user] = "#{name} <#{email}>"
732                 end
733         end
734 end
735
736 if file_list.empty?
737         usage
738         exit 1
739 end
740
741 SFX = ',v'
742
743 status = 0
744
745 rcs = []
746 file_list.each do |arg|
747         case ftype = File.ftype(arg)
748         when 'file'
749                 if arg[-2,2] == SFX
750                         if File.exists? arg
751                                 rcsfile = arg.dup
752                         else
753                                 not_found "RCS file #{arg}"
754                                 status |= 1
755                         end
756                         filename = File.basename(arg, SFX)
757                 else
758                         filename = File.basename(arg)
759                         path = File.dirname(arg)
760                         rcsfile = File.join(path, 'RCS', filename) + SFX
761                         unless File.exists? rcsfile
762                                 rcsfile.replace File.join(path, filename) + SFX
763                                 unless File.exists? rcsfile
764                                         not_found "RCS file for #{filename} in #{path}"
765                                 end
766                         end
767                 end
768                 rcs << RCS.parse(filename, rcsfile)
769         when 'directory'
770                 pattern = File.join(arg, '**', '*' + SFX)
771                 Dir.glob(pattern).each do |rcsfile|
772                         filename = File.basename(rcsfile, SFX)
773                         path = File.dirname(rcsfile)
774                         path.sub!(/\/?RCS$/, '') # strip final /RCS if present
775                         path.sub!(/^#{Regexp.escape arg}\/?/, '') # strip initial dirname
776                         filename = File.join(path, filename) unless path.empty?
777                         begin
778                                 rcs << RCS.parse(filename, rcsfile)
779                         rescue Exception => e
780                                 STDERR.puts "Failed to parse #{filename} @ #{rcsfile}:#{$.}"
781                                 raise e
782                         end
783                 end
784         else
785                 STDERR.puts "Cannot handle #{arg} of #{ftype} type"
786                 status |= 1
787         end
788 end
789
790 if rcs.length == 1
791         rcs.first.export_commits(parse_options)
792 else
793         STDERR.puts "Preparing commits"
794
795         commits = []
796
797         rcs.each do |r|
798                 r.revision.each do |k, rev|
799                         commits << RCS::Commit.new(r, rev)
800                 end
801         end
802
803         STDERR.puts "Sorting by date"
804
805         commits.sort!
806
807         if $DEBUG
808                 STDERR.puts "RAW commits (#{commits.length}):"
809                 commits.each do |c|
810                         PP.pp c.to_a, $stderr
811                 end
812         else
813                 STDERR.puts "#{commits.length} single-file commits"
814         end
815
816         STDERR.puts "Coalescing [1] by date fuzz"
817
818         commits.reverse_each do |c|
819                 commits.reverse_each do |k|
820                         break if k.date < c.date - parse_options[:commit_fuzz]
821                         next if k == c
822                         next if c.log != k.log or c.symbols != k.symbols or c.author != k.author or c.branch != k.branch
823                         next if k.date > c.date
824                         c.merge! k
825                         commits.delete k
826                 end
827         end
828
829         if $DEBUG
830                 STDERR.puts "[1] commits (#{commits.length}):"
831                 commits.each do |c|
832                         PP.pp c.to_a, $stderr
833                 end
834         else
835                 STDERR.puts "#{commits.length} coalesced commits"
836         end
837
838         commits.each { |c| c.export(parse_options) }
839
840 end
841
842 exit status