1#!/usr/bin/env ruby
2#
3# svnlook.rb : a Ruby-based replacement for svnlook
4#
5######################################################################
6#    Licensed to the Apache Software Foundation (ASF) under one
7#    or more contributor license agreements.  See the NOTICE file
8#    distributed with this work for additional information
9#    regarding copyright ownership.  The ASF licenses this file
10#    to you under the Apache License, Version 2.0 (the
11#    "License"); you may not use this file except in compliance
12#    with the License.  You may obtain a copy of the License at
13#
14#      http://www.apache.org/licenses/LICENSE-2.0
15#
16#    Unless required by applicable law or agreed to in writing,
17#    software distributed under the License is distributed on an
18#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
19#    KIND, either express or implied.  See the License for the
20#    specific language governing permissions and limitations
21#    under the License.
22######################################################################
23#
24
25require "svn/core"
26require "svn/fs"
27require "svn/delta"
28require "svn/repos"
29
30# Chomp off trailing slashes
31def basename(path)
32  path.chomp("/")
33end
34
35# SvnLook: a Ruby-based replacement for svnlook
36class SvnLook
37
38  # Initialize the SvnLook application
39  def initialize(path, rev, txn)
40    # Open a repository
41    @fs = Svn::Repos.open(basename(path)).fs
42
43    # If a transaction was specified, open it
44    if txn
45      @txn = @fs.open_txn(txn)
46    else
47      # Use the latest revision from the repo,
48      # if they haven't specified a revision
49      @txn = nil
50      rev ||= @fs.youngest_rev
51    end
52
53    @rev = rev
54  end
55
56  # Dispatch all commands to appropriate subroutines
57  def run(cmd, *args)
58    dispatch(cmd, *args)
59  end
60
61  private
62
63  # Dispatch all commands to appropriate subroutines
64  def dispatch(cmd, *args)
65    if respond_to?("cmd_#{cmd}", true)
66      begin
67        __send__("cmd_#{cmd}", *args)
68      rescue ArgumentError
69        puts $!.message
70        puts $@
71        puts("invalid argument for #{cmd}: #{args.join(' ')}")
72      end
73    else
74      puts("unknown command: #{cmd}")
75    end
76  end
77
78  # Default command: Run the 'info' and 'tree' commands
79  def cmd_default
80    cmd_info
81    cmd_tree
82  end
83
84  # Print the 'author' of the specified revision or transaction
85  def cmd_author
86    puts(property(Svn::Core::PROP_REVISION_AUTHOR) || "")
87  end
88
89  # Not implemented yet
90  def cmd_cat
91  end
92
93  # Find out what has changed in the specified revision or transaction
94  def cmd_changed
95    print_tree(ChangedEditor, nil, true)
96  end
97
98  # Output the date that the current revision was committed.
99  def cmd_date
100    if @txn
101      # It's not committed yet, so output nothing
102      puts
103    else
104      # Get the time the revision was committed
105      date = property(Svn::Core::PROP_REVISION_DATE)
106
107      if date
108        # Print out the date in a nice format
109        puts date.strftime('%Y-%m-%d %H:%M(%Z)')
110      else
111        # The specified revision doesn't have an associated date.
112        # Output just a blank line.
113        puts
114      end
115    end
116  end
117
118  # Output what changed in the specified revision / transaction
119  def cmd_diff
120    print_tree(DiffEditor, nil, true)
121  end
122
123  # Output what directories changed in the specified revision / transaction
124  def cmd_dirs_changed
125    print_tree(DirsChangedEditor)
126  end
127
128  # Output the tree, with node ids
129  def cmd_ids
130    print_tree(Editor, 0, true)
131  end
132
133  # Output the author, date, and the log associated with the specified
134  # revision / transaction
135  def cmd_info
136    cmd_author
137    cmd_date
138    cmd_log(true)
139  end
140
141  # Output the log message associated with the specified revision / transaction
142  def cmd_log(print_size=false)
143    log = property(Svn::Core::PROP_REVISION_LOG) || ''
144    puts log.length if print_size
145    puts log
146  end
147
148  # Output the tree associated with the provided tree
149  def cmd_tree
150    print_tree(Editor, 0)
151  end
152
153  # Output the repository's UUID.
154  def cmd_uuid
155    puts @fs.uuid
156  end
157
158  # Output the repository's youngest revision.
159  def cmd_youngest
160    puts @fs.youngest_rev
161  end
162
163  # Return a property of the specified revision or transaction.
164  # Name: the ID of the property you want to retrieve.
165  #       E.g. Svn::Core::PROP_REVISION_LOG
166  def property(name)
167    if @txn
168      @txn.prop(name)
169    else
170      @fs.prop(name, @rev)
171    end
172  end
173
174  # Print a tree of differences between two revisions
175  def print_tree(editor_class, base_rev=nil, pass_root=false)
176    if base_rev.nil?
177      if @txn
178        # Output changes since the base revision of the transaction
179        base_rev = @txn.base_revision
180      else
181        # Output changes since the previous revision
182        base_rev = @rev - 1
183      end
184    end
185
186    # Get the root of the specified transaction or revision
187    if @txn
188      root = @txn.root
189    else
190      root = @fs.root(@rev)
191    end
192
193    # Get the root of the base revision
194    base_root = @fs.root(base_rev)
195
196    # Does the provided editor need to know
197    # the revision and base revision we're working with?
198    if pass_root
199      # Create a new editor with the provided root and base_root
200      editor = editor_class.new(root, base_root)
201    else
202      # Create a new editor with nil root and base_roots
203      editor = editor_class.new
204    end
205
206    # Do a directory delta between the two roots with
207    # the specified editor
208    base_root.dir_delta('', '', root, '', editor)
209  end
210
211  # Output the current tree for a specified revision
212  class Editor < Svn::Delta::BaseEditor
213
214    # Initialize the Editor object
215    def initialize(root=nil, base_root=nil)
216      @root = root
217      # base_root ignored
218
219      @indent = ""
220    end
221
222    # Recurse through the root (and increase the indent level)
223    def open_root(base_revision)
224      puts "/#{id('/')}"
225      @indent << ' '
226    end
227
228    # If a directory is added, output this and increase
229    # the indent level
230    def add_directory(path, *args)
231      puts "#{@indent}#{basename(path)}/#{id(path)}"
232      @indent << ' '
233    end
234
235    alias open_directory add_directory
236
237    # If a directory is closed, reduce the ident level
238    def close_directory(baton)
239      @indent.chop!
240    end
241
242    # If a file is added, output that it has been changed
243    def add_file(path, *args)
244      puts "#{@indent}#{basename(path)}#{id(path)}"
245    end
246
247    alias open_file add_file
248
249    # Private methods
250    private
251
252    # Get the node id of a particular path
253    def id(path)
254      if @root
255        fs_id = @root.node_id(path)
256        " <#{fs_id.unparse}>"
257      else
258        ""
259      end
260    end
261  end
262
263
264  # Output directories that have been changed.
265  # In this class, methods such as open_root and add_file
266  # are inherited from Svn::Delta::ChangedDirsEditor.
267  class DirsChangedEditor < Svn::Delta::ChangedDirsEditor
268
269    # Private functions
270    private
271
272    # Print out the name of a directory if it has been changed.
273    # But only do so once.
274    # This behaves in a way like a callback function does.
275    def dir_changed(baton)
276      if baton[0]
277        # The directory hasn't been printed yet,
278        # so print it out.
279        puts baton[1] + '/'
280
281        # Make sure we don't print this directory out twice
282        baton[0] = nil
283      end
284    end
285  end
286
287  # Output files that have been changed between two roots
288  class ChangedEditor < Svn::Delta::BaseEditor
289
290    # Constructor
291    def initialize(root, base_root)
292      @root = root
293      @base_root = base_root
294    end
295
296    # Look at the root node
297    def open_root(base_revision)
298      # Nothing has been printed out yet, so return 'true'.
299      [true, '']
300    end
301
302    # Output deleted files
303    def delete_entry(path, revision, parent_baton)
304      # Output deleted paths with a D in front of them
305      print "D   #{path}"
306
307      # If we're deleting a directory,
308      # indicate this with a trailing slash
309      if @base_root.dir?('/' + path)
310        puts "/"
311      else
312        puts
313      end
314    end
315
316    # Output that a directory has been added
317    def add_directory(path, parent_baton,
318                      copyfrom_path, copyfrom_revision)
319      # Output 'A' to indicate that the directory was added.
320      # Also put a trailing slash since it's a directory.
321      puts "A   #{path}/"
322
323      # The directory has been printed -- don't print it again.
324      [false, path]
325    end
326
327    # Recurse inside directories
328    def open_directory(path, parent_baton, base_revision)
329      # Nothing has been printed out yet, so return true.
330      [true, path]
331    end
332
333    def change_dir_prop(dir_baton, name, value)
334      # Has the directory been printed yet?
335      if dir_baton[0]
336        # Print the directory
337        puts "_U  #{dir_baton[1]}/"
338
339        # Don't let this directory get printed again.
340        dir_baton[0] = false
341      end
342    end
343
344    def add_file(path, parent_baton,
345                 copyfrom_path, copyfrom_revision)
346      # Output that a directory has been added
347      puts "A   #{path}"
348
349      # We've already printed out this entry, so return '_'
350      # to prevent it from being printed again
351      ['_', ' ', nil]
352    end
353
354
355    def open_file(path, parent_baton, base_revision)
356      # Changes have been made -- return '_' to indicate as such
357      ['_', ' ', path]
358    end
359
360    def apply_textdelta(file_baton, base_checksum)
361      # The file has been changed -- we'll print that out later.
362      file_baton[0] = 'U'
363      nil
364    end
365
366    def change_file_prop(file_baton, name, value)
367      # The file has been changed -- we'll print that out later.
368      file_baton[1] = 'U'
369    end
370
371    def close_file(file_baton, text_checksum)
372      text_mod, prop_mod, path = file_baton
373      # Test the path. It will be nil if we added this file.
374      if path
375        status = text_mod + prop_mod
376        # Was there some kind of change?
377        if status != '_ '
378          puts "#{status}  #{path}"
379        end
380      end
381    end
382  end
383
384  # Output diffs of files that have been changed
385  class DiffEditor < Svn::Delta::BaseEditor
386
387    # Constructor
388    def initialize(root, base_root)
389      @root = root
390      @base_root = base_root
391    end
392
393    # Handle deleted files and directories
394    def delete_entry(path, revision, parent_baton)
395      # Print out diffs of deleted files, but not
396      # deleted directories
397      unless @base_root.dir?('/' + path)
398        do_diff(path, nil)
399      end
400    end
401
402    # Handle added files
403    def add_file(path, parent_baton,
404                 copyfrom_path, copyfrom_revision)
405      # If a file has been added, print out the diff.
406      do_diff(nil, path)
407
408      ['_', ' ', nil]
409    end
410
411    # Handle files
412    def open_file(path, parent_baton, base_revision)
413      ['_', ' ', path]
414    end
415
416    # If a file is changed, print out the diff
417    def apply_textdelta(file_baton, base_checksum)
418      if file_baton[2].nil?
419        nil
420      else
421        do_diff(file_baton[2], file_baton[2])
422      end
423    end
424
425    private
426
427    # Print out a diff between two paths
428    def do_diff(base_path, path)
429      if base_path.nil?
430        # If there's no base path, then the file
431        # must have been added
432        puts("Added: #{path}")
433        name = path
434      elsif path.nil?
435        # If there's no new path, then the file
436        # must have been deleted
437        puts("Removed: #{base_path}")
438        name = base_path
439      else
440        # Otherwise, the file must have been modified
441        puts "Modified: #{path}"
442        name = path
443      end
444
445      # Set up labels for the two files
446      base_label = "#{name} (original)"
447      label = "#{name} (new)"
448
449      # Output a unified diff between the two files
450      puts "=" * 78
451      differ = Svn::Fs::FileDiff.new(@base_root, base_path, @root, path)
452      puts differ.unified(base_label, label)
453      puts
454    end
455  end
456end
457
458# Output usage message and exit
459def usage
460  messages = [
461    "usage: #{$0} REPOS_PATH rev REV [COMMAND] - inspect revision REV",
462    "       #{$0} REPOS_PATH txn TXN [COMMAND] - inspect transaction TXN",
463    "       #{$0} REPOS_PATH [COMMAND] - inspect the youngest revision",
464    "",
465    "REV is a revision number > 0.",
466    "TXN is a transaction name.",
467    "",
468    "If no command is given, the default output (which is the same as",
469    "running the subcommands `info' then `tree') will be printed.",
470    "",
471    "COMMAND can be one of: ",
472    "",
473    "   author:        print author.",
474    "   changed:       print full change summary: all dirs & files changed.",
475    "   date:          print the timestamp (revisions only).",
476    "   diff:          print GNU-style diffs of changed files and props.",
477    "   dirs-changed:  print changed directories.",
478    "   ids:           print the tree, with nodes ids.",
479    "   info:          print the author, data, log_size, and log message.",
480    "   log:           print log message.",
481    "   tree:          print the tree.",
482    "   uuid:          print the repository's UUID (REV and TXN ignored).",
483    "   youngest:      print the youngest revision number (REV and TXN ignored).",
484  ]
485  puts(messages.join("\n"))
486  exit(1)
487end
488
489# Output usage if necessary
490if ARGV.empty?
491  usage
492end
493
494# Process arguments
495path = ARGV.shift
496cmd = ARGV.shift
497rev = nil
498txn = nil
499
500case cmd
501when "rev"
502  rev = Integer(ARGV.shift)
503  cmd = ARGV.shift
504when "txn"
505  txn = ARGV.shift
506  cmd = ARGV.shift
507end
508
509# If no command is specified, use the default
510cmd ||= "default"
511
512# Replace dashes in the command with underscores
513cmd = cmd.gsub(/-/, '_')
514
515# Start SvnLook with the specified command
516SvnLook.new(path, rev, txn).run(cmd)
517