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