Initial commit
[rcs-fast-export] / rcs-fast-export.rb
1 #!/usr/bin/ruby
2
3 require 'pp'
4
5 def usage
6         STDERR.puts "#{$0} filename -- fast-export filename's RCS history"
7 end
8
9 def not_found(arg)
10         STDERR.puts "Could not find #{arg}"
11 end
12
13 class Time
14         def Time.rcs(string)
15                 fields = string.split('.')
16                 raise ArgumentError, "wrong number of fields for RCS date #{string}" unless fields.length == 6
17                 Time.utc(*fields)
18         end
19 end
20
21 module RCS
22         # strip an optional final ;
23         def RCS.clean(arg)
24                 arg.chomp(';')
25         end
26
27         # strip the first and last @, and de-double @@s
28         def RCS.sanitize(arg)
29                 case arg
30                 when Array
31                         ret = arg.dup
32                         raise 'malformed first line' unless ret.first[0,1] == '@'
33                         raise 'malformed last line' unless ret.last[-1,1] == '@'
34                         ret.first.sub!(/^@/,'')
35                         ret.last.sub!(/@$/,'')
36                         ret.map { |l| l.gsub('@@','@') }
37                 when String
38                         arg.chomp('@').sub(/^@/,'').gsub('@@','@')
39                 else
40                         raise
41                 end
42         end
43
44         # clean and sanitize
45         def RCS.at_clean(arg)
46                 RCS.sanitize RCS.clean(arg)
47         end
48
49         def RCS.blob(arg)
50                 arg.gsub('.', '0') + ('90'*5)
51         end
52
53         def RCS.commit(arg)
54                 arg.gsub('.', '0') + ('09'*5)
55         end
56
57         class File
58                 attr_accessor :head, :comment, :desc, :revision
59                 def initialize(fname)
60                         @fname = fname.dup
61                         @head = nil
62                         @comment = nil
63                         @desc = []
64                         @revision = Hash.new { |h, r| h[r] = Revision.new(r) }
65                 end
66
67                 def has_revision?(rev)
68                         @revision.has_key?(rev) and not @revision[rev].author.nil?
69                 end
70
71                 def export_commits
72                         counter = 0
73                         exported = []
74                         until @revision.empty?
75                                 counter += 1
76
77                                 # a string sort is a very good candidate for
78                                 # export order, getting a miss only for
79                                 # multi-digit revision components
80                                 keys = @revision.keys.sort
81
82                                 STDERR.puts "commit export loop ##{counter}"
83                                 STDERR.puts "\t#{exported.length} commits exported so far: #{exported.join(', ')}" unless exported.empty?
84                                 STDERR.puts "\t#{keys.size} to export: #{keys.join(', ')}"
85
86                                 keys.each do |key|
87                                         rev = @revision[key]
88                                         # the parent commit is rev.next if we're on the
89                                         # master branch (rev.branch is nil) or
90                                         # rev.diff_base otherwise
91                                         from = rev.branch.nil? ? rev.next : rev.diff_base
92                                         # A commit can only be exported if it has no
93                                         # parent, or if the parent has been exported
94                                         # already. Skip this commit otherwise
95                                         if from and not exported.include? from
96                                                 next
97                                         end
98
99                                         branch = rev.branch || 'master'
100                                         # TODO map authors to author/email
101                                         author = "#{rev.author} <empty>"
102                                         date = "#{rev.date.tv_sec} +0000"
103                                         log = rev.log.to_s
104
105                                         puts "commit refs/heads/#{branch}"
106                                         puts "mark :#{RCS.commit key}"
107                                         puts "committer #{author} #{date}"
108                                         puts "data #{log.length}"
109                                         puts log unless log.empty?
110                                         puts "from :#{RCS.commit from}" if rev.branch_point
111                                         puts "M 644 :#{RCS.blob key} #{@fname}"
112                                         exported.push key
113                                 end
114                                 exported.each { |k| @revision.delete(k) }
115                         end
116                 end
117         end
118
119         class Revision
120                 attr_accessor :rev, :author, :date, :state, :next
121                 attr_accessor :branches, :log, :text
122                 attr_accessor :branch, :diff_base, :branch_point
123                 def initialize(rev)
124                         @rev = rev
125                         @author = nil
126                         @date = nil
127                         @state = nil
128                         @next = nil
129                         @branches = []
130                         @branch = nil
131                         @branch_point = nil
132                         @diff_base = nil
133                         @log = []
134                         @text = []
135                 end
136
137                 def date=(str)
138                         @date = Time.rcs(str)
139                 end
140
141                 def blob
142                         str = @text.join('')
143                         ret = "blob\nmark :#{RCS.blob @rev}\ndata #{str.length}\n#{str}\n"
144                         ret
145                 end
146         end
147
148         def RCS.parse(fname, rcsfile, opts={})
149                 rcs = RCS::File.new(fname)
150
151                 ::File.open(rcsfile, 'r') do |file|
152                         status = [:basic]
153                         rev = nil
154                         lines = []
155                         difflines = []
156                         file.each_line do |line|
157                                 case status.last
158                                 when :basic
159                                         command, args = line.split($;,2)
160                                         next if command.empty?
161
162                                         case command
163                                         when 'head'
164                                                 rcs.head = RCS.clean(args.chomp)
165                                         when 'comment'
166                                                 rcs.comment = RCS.at_clean(args.chomp)
167                                         when /^[0-9.]+$/
168                                                 rev = command.dup
169                                                 if rcs.has_revision?(rev)
170                                                         status.push :revision_data
171                                                 else
172                                                         status.push :new_revision
173                                                 end
174                                         when 'desc'
175                                                 status.push :desc
176                                                 lines.clear
177                                                 status.push :read_lines
178                                         else
179                                                 STDERR.puts "Skipping unhandled command #{command.inspect}"
180                                         end
181                                 when :desc
182                                         rcs.desc.replace lines.dup
183                                         status.pop
184                                 when :read_lines
185                                         # we sanitize lines as we read them
186
187                                         actual_line = line.dup
188
189                                         # the first line must begin with a @, which we strip
190                                         if lines.empty?
191                                                 ats = line.match(/^@+/)
192                                                 raise 'malformed line' unless ats
193                                                 actual_line.replace line.sub(/^@/,'')
194                                         end
195
196                                         # if the line ends with an ODD number of @, it's the
197                                         # last line -- we work on actual_line so that content
198                                         # such as @\n or @ work correctly (they would be
199                                         # encoded respectively as ['@@@\n','@\n'] and
200                                         # ['@@@@\n']
201                                         ats = actual_line.chomp.match(/@+$/)
202                                         if nomore = (ats && Regexp.last_match(0).length.odd?)
203                                                 actual_line.replace actual_line.chomp.sub(/@$/,'')
204                                         end
205                                         lines << actual_line.gsub('@@','@')
206                                         if nomore
207                                                 status.pop
208                                                 redo
209                                         end
210                                 when :new_revision
211                                         case line.chomp
212                                         when /^date\s+(\S+);\s+author\s+(\S+);\sstate\s(\S+);$/
213                                                 rcs.revision[rev].date = $1
214                                                 rcs.revision[rev].author = $2
215                                                 rcs.revision[rev].state = $3
216                                         when 'branches'
217                                                 status.push :branches
218                                         when 'branches;'
219                                                 next
220                                         when /^next\s+(\S+)?;$/
221                                                 nxt = rcs.revision[rev].next = $1
222                                                 next unless nxt
223                                                 raise "multiple diff_bases for #{nxt}" unless rcs.revision[nxt].diff_base.nil?
224                                                 rcs.revision[nxt].diff_base = rev
225                                                 rcs.revision[nxt].branch = rcs.revision[rev].branch
226                                         else
227                                                 status.pop
228                                         end
229                                 when :branches
230                                         candidate = line.split(';',2)
231                                         branch = candidate.first.strip
232                                         rcs.revision[rev].branches.push branch
233                                         raise "multiple diff_bases for #{branch}" unless rcs.revision[branch].diff_base.nil?
234                                         rcs.revision[branch].diff_base = rev
235                                         # we drop the last number from the branch name
236                                         rcs.revision[branch].branch = branch.sub(/\.\d+$/,'')
237                                         rcs.revision[branch].branch_point = rev
238                                         status.pop if candidate.length > 1
239                                 when :revision_data
240                                         case line.chomp
241                                         when 'log'
242                                                 status.push :log
243                                                 lines.clear
244                                                 status.push :read_lines
245                                         when 'text'
246                                                 if rev == rcs.head
247                                                         status.push :head
248                                                 else
249                                                         status.push :diff
250                                                 end
251                                                 lines.clear
252                                                 status.push :read_lines
253                                         else
254                                                 status.pop
255                                         end
256                                 when :log
257                                         rcs.revision[rev].log.replace lines.dup
258                                         status.pop
259                                 when :head
260                                         rcs.revision[rev].text.replace lines.dup
261                                         puts rcs.revision[rev].blob
262                                         status.pop
263                                 when :diff
264                                         difflines.replace lines.dup
265                                         difflines.pop if difflines.last.empty?
266                                         base = rcs.revision[rev].diff_base
267                                         unless rcs.revision[base].text
268                                                 pp rcs
269                                                 puts rev, base
270                                                 raise 'no diff base!'
271                                         end
272                                         # deep copy
273                                         buffer = []
274                                         rcs.revision[base].text.each { |l| buffer << l.dup }
275
276                                         adding = false
277                                         index = -1
278                                         count = -1
279
280                                         while l = difflines.shift
281                                                 if adding
282                                                         buffer[index] << l
283                                                         count -= 1
284                                                         adding = false unless count > 0
285                                                         next
286                                                 end
287
288                                                 l.chomp!
289                                                 raise 'malformed diff' unless l =~ /^([ad])(\d+) (\d+)$/
290                                                 index = $2.to_i-1
291                                                 count = $3.to_i
292                                                 case $1.intern
293                                                 when :d
294                                                         # we replace them with empty string so that 'a' commands
295                                                         # referring to the same line work properly
296                                                         while count > 0
297                                                                 buffer[index].replace ''
298                                                                 index += 1
299                                                                 count -= 1
300                                                         end
301                                                 when :a
302                                                         adding = true
303                                                 end
304                                         end
305
306                                         # remove empty lines
307                                         buffer.delete_if { |l| l.empty? }
308
309                                         rcs.revision[rev].text = buffer
310                                         puts rcs.revision[rev].blob
311                                         status.pop
312                                 else
313                                         STDERR.puts "Unknown status #{status.last}"
314                                         exit 1
315                                 end
316                         end
317                 end
318                 rcs.export_commits
319         end
320 end
321
322 arg=ARGV[0]
323
324 if arg.nil?
325         usage
326         exit 1
327 end
328
329 SFX = ',v'
330
331 if arg[-2,2] == SFX
332         if File.exists? arg
333                 rcsfile = arg.dup
334         else
335                 not_found "RCS file #{arg}"
336                 exit 1
337         end
338         filename = File.basename(arg, SFX)
339 else
340         filename = File.basename(arg)
341         path = File.dirname(arg)
342         rcsfile = File.join(path, 'RCS', filename) + SFX
343         unless File.exists? rcsfile
344                 rcsfile.replace File.join(path, filename) + SFX
345                 unless File.exists? rcsfile
346                         not_found "RCS file for #{filename} in #{path}"
347                 end
348         end
349 end
350
351 RCS.parse(filename, rcsfile)