diff --git a/CHANGELOG b/CHANGELOG
index f40d8977..7b3a24a4 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,7 @@
*SVN*
+* Added CVS module (very very experimental!)
+
* Works with public keys now, for passwordless deployment
* Subversion module recognizes the password prompt for HTTP authentication
diff --git a/lib/switchtower/scm/cvs.rb b/lib/switchtower/scm/cvs.rb
new file mode 100644
index 00000000..a4c588e6
--- /dev/null
+++ b/lib/switchtower/scm/cvs.rb
@@ -0,0 +1,82 @@
+require 'time'
+
+module SwitchTower
+ module SCM
+
+ # An SCM module for using CVS as your source control tool. You can
+ # specify it by placing the following line in your configuration:
+ #
+ # set :scm, :cvs
+ #
+ # Also, this module accepts a :cvs configuration variable,
+ # which (if specified) will be used as the full path to the cvs
+ # executable on the remote machine:
+ #
+ # set :cvs, "/opt/local/bin/cvs"
+ #
+ # You can specify the location of your local copy (used to query
+ # the revisions, etc.) via the :local variable, which defaults to
+ # ".".
+ #
+ # Also, you can specify the CVS_RSH variable to use on the remote machine(s)
+ # via the :cvs_rsh variable. This defaults to the value of the
+ # CVS_RSH environment variable locally, or if it is not set, to "ssh".
+ class Cvs
+ attr_reader :configuration
+
+ def initialize(configuration) #:nodoc:
+ @configuration = configuration
+ end
+
+ # Return a string representing the date of the last revision (CVS is
+ # seriously retarded, in that it does not give you a way to query when
+ # the last revision was made to the repository, so this is a fairly
+ # expensive operation...)
+ def latest_revision
+ return @latest_revision if @latest_revision
+ configuration.logger.debug "querying latest revision..."
+ @latest_revision = cvs_log(configuration.local).
+ split(/\r?\n/).
+ grep(/^date: (.*?);/) { Time.parse($1).strftime("%FT%T") }.
+ sort.
+ last
+ end
+
+ # Check out (on all servers associated with the current task) the latest
+ # revision. Uses the given actor instance to execute the command.
+ def checkout(actor)
+ cvs = configuration[:cvs] || "cvs"
+ cvs_rsh = configuration[:cvs_rsh] || ENV['CVS_RSH'] || "ssh"
+
+ command = <<-CMD
+ if [[ -d #{actor.release_path} ]]; then
+ cd #{actor.release_path};
+ CVS_RSH="#{cvs_rsh}" #{cvs} -q up -d#{latest_revision};
+ else
+ cd #{configuration.releases_path};
+ CVS_RSH="#{cvs_rsh}" #{cvs} -d #{configuration.repository} -q co -D #{latest_revision} -d #{latest_revision} #{actor.application};
+ fi
+ CMD
+ actor.run(command) do |ch, stream, out|
+ prefix = "#{stream} :: #{ch[:host]}"
+ actor.logger.info out, prefix
+ if out =~ %r{password:}
+ actor.logger.info "CVS is asking for a password", prefix
+ ch.send_data "#{actor.password}\n"
+ elsif out =~ %r{^Enter passphrase}
+ message = "CVS needs your key's passphrase and cannot proceed"
+ actor.logger.info message, prefix
+ raise message
+ end
+ end
+ end
+
+ private
+
+ def cvs_log(path)
+ `cd #{path || "."} && cvs -q log -N -rHEAD`
+ end
+ end
+
+ end
+end
diff --git a/test/scm/cvs_test.rb b/test/scm/cvs_test.rb
new file mode 100644
index 00000000..3df64cce
--- /dev/null
+++ b/test/scm/cvs_test.rb
@@ -0,0 +1,159 @@
+$:.unshift File.dirname(__FILE__) + "/../../lib"
+
+require File.dirname(__FILE__) + "/../utils"
+require 'test/unit'
+require 'switchtower/scm/cvs'
+
+class ScmCvsTest < Test::Unit::TestCase
+ class CvsTest < SwitchTower::SCM::Cvs
+ attr_accessor :story
+ attr_reader :last_path
+
+ def cvs_log(path)
+ @last_path = path
+ story.shift
+ end
+ end
+
+ class MockChannel
+ attr_reader :sent_data
+
+ def send_data(data)
+ @sent_data ||= []
+ @sent_data << data
+ end
+
+ def [](name)
+ "value"
+ end
+ end
+
+ class MockActor
+ attr_reader :command
+ attr_reader :channels
+ attr_accessor :story
+
+ def initialize(config)
+ @config = config
+ end
+
+ def run(command)
+ @command = command
+ @channels ||= []
+ @channels << MockChannel.new
+ story.each { |stream, line| yield @channels.last, stream, line }
+ end
+
+ def method_missing(sym, *args)
+ @config.send(sym, *args)
+ end
+ end
+
+ def setup
+ @config = MockConfiguration.new
+ @config[:repository] = ":ext:joetester@rubyforge.org:/hello/world"
+ @config[:local] = "/hello/world"
+ @config[:cvs] = "/path/to/cvs"
+ @config[:password] = "chocolatebrownies"
+ @scm = CvsTest.new(@config)
+ @actor = MockActor.new(@config)
+ @log_msg = <