5 # Integer#odd? was introduced in Ruby 1.8.7, backport it to
7 unless 2.respond_to? :odd?
17 #{$0} [options] file [file ...]
19 Fast-export the RCS history of one or more file.
22 --help, -h, -? display this help text
23 --authors-file, -A specify a file containing username = Full Name <email> mappings
24 --[no-]tag-each-rev [do not] create a lightweight tag for each RCS revision
27 rcs.authorsFile for --authors-file
28 rcs.tagEachRev for --tag-each-rev
34 STDERR.puts "Could not find #{arg}"
37 # returns a hash that maps usernames to author names & emails
38 def load_authors_file(fn)
41 File.open(File.expand_path fn) do |io|
42 io.each_line do |line|
43 uname, author = line.split('=', 2)
46 STDERR.puts "Username #{uname} redefined to #{author}" if hash.has_key? uname
58 fields = string.split('.')
59 raise ArgumentError, "wrong number of fields for RCS date #{string}" unless fields.length == 6
65 # strip an optional final ;
70 # strip the first and last @, and de-double @@s
75 raise 'malformed first line' unless ret.first[0,1] == '@'
76 raise 'malformed last line' unless ret.last[-1,1] == '@'
77 ret.first.sub!(/^@/,'')
78 ret.last.sub!(/@$/,'')
79 ret.map { |l| l.gsub('@@','@') }
81 arg.chomp('@').sub(/^@/,'').gsub('@@','@')
89 RCS.sanitize RCS.clean(arg)
93 arg.gsub('.', '0') + ('90'*5)
97 arg.gsub('.', '0') + ('09'*5)
101 attr_accessor :head, :comment, :desc, :revision
102 def initialize(fname)
107 @revision = Hash.new { |h, r| h[r] = Revision.new(r) }
110 def has_revision?(rev)
111 @revision.has_key?(rev) and not @revision[rev].author.nil?
114 def export_commits(opts={})
117 until @revision.empty?
120 # a string sort is a very good candidate for
121 # export order, getting a miss only for
122 # multi-digit revision components
123 keys = @revision.keys.sort
125 STDERR.puts "commit export loop ##{counter}"
126 STDERR.puts "\t#{exported.length} commits exported so far: #{exported.join(', ')}" unless exported.empty?
127 STDERR.puts "\t#{keys.size} to export: #{keys.join(', ')}"
131 # the parent commit is rev.next if we're on the
132 # master branch (rev.branch is nil) or
133 # rev.diff_base otherwise
134 from = rev.branch.nil? ? rev.next : rev.diff_base
135 # A commit can only be exported if it has no
136 # parent, or if the parent has been exported
137 # already. Skip this commit otherwise
138 if from and not exported.include? from
142 branch = rev.branch || 'master'
143 author = opts[:authors][rev.author] || "#{rev.author} <empty>"
144 date = "#{rev.date.tv_sec} +0000"
147 puts "commit refs/heads/#{branch}"
148 puts "mark :#{RCS.commit key}"
149 puts "committer #{author} #{date}"
150 puts "data #{log.length}"
151 puts log unless log.empty?
152 puts "from :#{RCS.commit from}" if rev.branch_point
153 puts "M 644 :#{RCS.blob key} #{@fname}"
155 # TODO FIXME this *should* be safe, in
156 # that it should not unduly move
157 # branches back in time, but I'm not
159 rev.branches.each do |sym|
160 puts "reset refs/heads/#{sym}"
161 puts "from :#{RCS.commit key}"
163 rev.symbols.each do |sym|
164 puts "reset refs/tags/#{sym}"
165 puts "from :#{RCS.commit key}"
167 if opts[:tag_each_rev]
168 puts "reset refs/tags/#{key}"
169 puts "from :#{RCS.commit key}"
174 exported.each { |k| @revision.delete(k) }
180 attr_accessor :rev, :author, :date, :state, :next
181 attr_accessor :branches, :log, :text, :symbols
182 attr_accessor :branch, :diff_base, :branch_point
199 @date = Time.rcs(str)
204 ret = "blob\nmark :#{RCS.blob @rev}\ndata #{str.length}\n#{str}\n"
209 def RCS.parse(fname, rcsfile, opts={})
210 rcs = RCS::File.new(fname)
212 ::File.open(rcsfile, 'r') do |file|
217 file.each_line do |line|
220 command, args = line.split($;,2)
221 next if command.empty?
225 rcs.head = RCS.clean(args.chomp)
229 rcs.comment = RCS.at_clean(args.chomp)
232 if rcs.has_revision?(rev)
233 status.push :revision_data
235 status.push :new_revision
240 status.push :read_lines
242 STDERR.puts "Skipping unhandled command #{command.inspect}"
245 sym, rev = line.strip.split(':',2);
246 status.pop if rev.chomp!(';')
247 rcs.revision[rev].symbols << sym
249 rcs.desc.replace lines.dup
252 # we sanitize lines as we read them
254 actual_line = line.dup
256 # the first line must begin with a @, which we strip
258 ats = line.match(/^@+/)
259 raise 'malformed line' unless ats
260 actual_line.replace line.sub(/^@/,'')
263 # if the line ends with an ODD number of @, it's the
264 # last line -- we work on actual_line so that content
265 # such as @\n or @ work correctly (they would be
266 # encoded respectively as ['@@@\n','@\n'] and
268 ats = actual_line.chomp.match(/@+$/)
269 if nomore = (ats && Regexp.last_match(0).length.odd?)
270 actual_line.replace actual_line.chomp.sub(/@$/,'')
272 lines << actual_line.gsub('@@','@')
279 when /^date\s+(\S+);\s+author\s+(\S+);\sstate\s(\S+);$/
280 rcs.revision[rev].date = $1
281 rcs.revision[rev].author = $2
282 rcs.revision[rev].state = $3
284 status.push :branches
287 when /^next\s+(\S+)?;$/
288 nxt = rcs.revision[rev].next = $1
290 raise "multiple diff_bases for #{nxt}" unless rcs.revision[nxt].diff_base.nil?
291 rcs.revision[nxt].diff_base = rev
292 rcs.revision[nxt].branch = rcs.revision[rev].branch
297 candidate = line.split(';',2)
298 branch = candidate.first.strip
299 rcs.revision[rev].branches.push branch
300 raise "multiple diff_bases for #{branch}" unless rcs.revision[branch].diff_base.nil?
301 rcs.revision[branch].diff_base = rev
302 # we drop the last number from the branch name
303 rcs.revision[branch].branch = branch.sub(/\.\d+$/,'.x')
304 rcs.revision[branch].branch_point = rev
305 status.pop if candidate.length > 1
311 status.push :read_lines
319 status.push :read_lines
324 rcs.revision[rev].log.replace lines.dup
327 rcs.revision[rev].text.replace lines.dup
328 puts rcs.revision[rev].blob
331 difflines.replace lines.dup
332 difflines.pop if difflines.last.empty?
333 base = rcs.revision[rev].diff_base
334 unless rcs.revision[base].text
337 raise 'no diff base!'
341 rcs.revision[base].text.each { |l| buffer << [l.dup] }
347 while l = difflines.shift
349 raise 'negative index during insertion' if index < 0
350 raise 'negative count during insertion' if count < 0
353 # collected all the lines, put the before
355 buffer[index].unshift *adding
362 raise 'malformed diff' unless l =~ /^([ad])(\d+) (\d+)$/
368 # for deletion, index 1 is the first index, so the Ruby
369 # index is one less than the diff one
371 # we replace them with empty string so that 'a' commands
372 # referring to the same line work properly
379 # addition will prepend the appropriate lines
380 # to the given index, and in this case Ruby
381 # and diff indices are the same
386 # turn the buffer into an array of lines, deleting the empty ones
387 buffer.delete_if { |l| l.empty? }
390 rcs.revision[rev].text = buffer
391 puts rcs.revision[rev].blob
394 STDERR.puts "Unknown status #{status.last}"
400 # clean up the symbols/branches: look for revisions that have
401 # one or more symbols but no dates, and make them into
402 # branches, pointing to the highest commit with that key
404 keys = rcs.revision.keys
405 rcs.revision.each do |key, rev|
406 if rev.date.nil? and not rev.symbols.empty?
407 top = keys.select { |k| k.match(/^#{key}\./) }.sort.last
408 tr = rcs.revision[top]
409 raise "unhandled complex branch structure met: #{rev.inspect} refers #{tr.inspect}" if tr.date.nil?
410 tr.branches |= rev.symbols
414 branches.each { |k| rcs.revision.delete k }
417 rcs.export_commits(opts)
423 opts = GetoptLong.new(
424 # Authors file, like git-svn and git-cvsimport, more than one can be
426 ['--authors-file', '-A', GetoptLong::REQUIRED_ARGUMENT],
427 # RCS file suffix, like RCS
428 ['--rcs-suffixes', '-x', GetoptLong::REQUIRED_ARGUMENT],
430 ['--tag-each-rev', GetoptLong::NO_ARGUMENT],
431 ['--no-tag-each-rev', GetoptLong::NO_ARGUMENT],
432 ['--help', '-h', '-?', GetoptLong::NO_ARGUMENT]
435 # We read options in order, but they apply to all passed parameters.
436 # TODO maybe they should only apply to the following, unless there's only one
438 opts.ordering = GetoptLong::RETURN_IN_ORDER
442 :authors => Hash.new,
445 # Read config options
446 `git config --get-all rcs.authorsfile`.each_line do |fn|
447 parse_options[:authors].merge! load_authors_file(fn.chomp)
450 parse_options[:tag_each_rev] = (
451 `git config --bool rcs.tageachrev`.chomp == 'true'
454 opts.each do |opt, arg|
456 when '--authors-file'
457 authors = load_authors_file(arg)
458 redef = parse_options[:authors].keys & authors.keys
459 STDERR.puts "Authors file #{arg} redefines #{redef.join(', ')}" unless redef.empty?
460 parse_options[:authors].merge!(authors)
461 when '--rcs-suffixes'
463 when '--tag-each-rev'
464 parse_options[:tag_each_rev] = true
465 when '--no-tag-each-rev'
466 # this is the default, which is fine since the missing key
467 # (default) returns nil which is false in Ruby
468 parse_options[:tag_each_rev] = false
479 user = Etc.getlogin || ENV['USER']
481 # steal username/email data from other init files that may contain the
485 # the user's .hgrc file for a username field
486 ['~/.hgrc', /^\s*username\s*=\s*(["'])?(.*)\1$/, 2],
487 # the user's .(g)vimrc for a changelog_username setting
488 ['~/.vimrc', /changelog_username\s*=\s*(["'])?(.*)\1$/, 2],
489 ['~/.gvimrc', /changelog_username\s*=\s*(["'])?(.*)\1$/, 2],
491 ].each do |fn, rx, idx|
492 file = File.expand_path fn
493 if File.readable?(file) and File.read(file) =~ rx
494 parse_options[:authors][user] = Regexp.last_match(idx).strip
500 if user and not user.empty? and not parse_options[:authors].has_key?(user)
501 name = ENV['GIT_AUTHOR_NAME'] || ''
502 name.replace(`git config user.name`.chomp) if name.empty?
503 name.replace(Etc.getpwnam(user).gecos) if name.empty?
506 # couldn't find a name, try to steal data from other sources
509 # if we found a name, try to find an email too
510 email = ENV['GIT_AUTHOR_EMAIL'] || ''
511 email.replace(`git config user.email`.chomp) if email.empty?
514 # couldn't find an email, try to steal data too
517 # we got both a name and email, fill the info
518 parse_options[:authors][user] = "#{name} <#{email}>"
532 file_list.each do |arg|
537 not_found "RCS file #{arg}"
540 filename = File.basename(arg, SFX)
542 filename = File.basename(arg)
543 path = File.dirname(arg)
544 rcsfile = File.join(path, 'RCS', filename) + SFX
545 unless File.exists? rcsfile
546 rcsfile.replace File.join(path, filename) + SFX
547 unless File.exists? rcsfile
548 not_found "RCS file for #{filename} in #{path}"
553 RCS.parse(filename, rcsfile, parse_options)