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