capistrano 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CHANGELOG CHANGED
@@ -1,3 +1,57 @@
1
+ *2.1.0* October 14, 2007
2
+
3
+ * Default to 0664 instead of 0660 on upload [Jamis Buck]
4
+
5
+ * Fix deploy:pending to query SCM for the subsequent revision so that it does not include the last deployed change [Jamis Buck]
6
+
7
+ * Prefer 'Last Changed Rev' over 'Revision' when querying latest revision via Subversion [Jamis Buck]
8
+
9
+ * Explicitly require 'stringio' in copy_test [mislav]
10
+
11
+ * When Subversion#query_revision fails, give a more sane error [Jamis Buck]
12
+
13
+ * Don't run the upgrade:revisions task on non-release servers [Jamis Buck]
14
+
15
+ * Fix cap shell to properly recognize sudo prompt [Mark Imbriaco, barnaby, Jamis Buck]
16
+
17
+ * Git SCM module [Garry Dolley, Geoffrey Grosenbach, Scott Chacon]
18
+
19
+ * Use the --password switch for subversion by default, but add :scm_prefer_prompt variable (defaults to false) [Jamis Buck]
20
+
21
+
22
+ *2.0.100 (2.1 Preview 1)* September 1, 2007
23
+
24
+ * capify-generated Capfile will autoload all recipes from vendor/plugins/*/recipes/*.rb [Graeme Mathieson]
25
+
26
+ * Use sudo -p switch to set sudo password prompt to something predictable [Mike Bailey]
27
+
28
+ * Allow independent configurations to require the same recipe file [Jamis Buck]
29
+
30
+ * Set :shell to false to run a command without wrapping it in "sh -c" [Jamis Buck]
31
+
32
+ * Don't request a pty by default [Jamis Buck]
33
+
34
+ * Add a "match" remote dependency method [Adam Greene]
35
+
36
+ * Allow auth-caching of subversion credentials to be enabled via :scm_auth_cache [tsmith]
37
+
38
+ * Don't let a task trigger itself when used as the source for an "on" hook [Jamis Buck]
39
+
40
+ * Avoid using the --password switch with subversion for security purposes [sentinel]
41
+
42
+ * Add version_dir, current_dir, and shared_dir variables for naming the directories used in deployment [drinkingbird]
43
+
44
+ * Use Windows-safe binary reads for reading file contents [Ladislav Martincik]
45
+
46
+ * Add Accurev SCM support [Doug Barth]
47
+
48
+ * Use the :runner variable to determine who to sudo as for deploy:restart [Graham Ashton]
49
+
50
+ * Add Namespaces#top to always return a reference to the topmost namespace [Jamis Buck]
51
+
52
+ * Change the "-h" output so that it does not say that "-q" is the default [Jamis Buck]
53
+
54
+
1
55
  *2.0.0* July 21, 2007
2
56
 
3
57
  * Make the "no matching servers" error more sane [halorgium]
data/bin/capify CHANGED
@@ -37,6 +37,7 @@ end
37
37
  files = {
38
38
  "Capfile" => unindent(<<-FILE),
39
39
  load 'deploy' if respond_to?(:namespace) # cap2 differentiator
40
+ Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
40
41
  load 'config/deploy'
41
42
  FILE
42
43
 
@@ -37,5 +37,9 @@ module Capistrano
37
37
  def call
38
38
  config.find_and_execute_task(source)
39
39
  end
40
+
41
+ def applies_to?(task)
42
+ super && (task.nil? || task.fully_qualified_name != source.to_s)
43
+ end
40
44
  end
41
45
  end
@@ -54,7 +54,7 @@ module Capistrano
54
54
  ) { options[:password] = nil }
55
55
 
56
56
  opts.on("-q", "--quiet",
57
- "Make the output as quiet as possible (default)"
57
+ "Make the output as quiet as possible."
58
58
  ) { options[:verbose] = 0 }
59
59
 
60
60
  opts.on("-S", "--set-before NAME=VALUE",
@@ -180,4 +180,4 @@ module Capistrano
180
180
 
181
181
  end
182
182
  end
183
- end
183
+ end
@@ -83,24 +83,39 @@ module Capistrano
83
83
  channel[:server] = server
84
84
  channel[:host] = server.host
85
85
  channel[:options] = options
86
- channel.request_pty :want_reply => true
87
86
 
88
- channel.on_success do |ch|
87
+ execute_command = Proc.new do |ch|
89
88
  logger.trace "executing command", ch[:server] if logger
90
- escaped = replace_placeholders(command, ch).gsub(/[$\\`"]/) { |m| "\\#{m}" }
91
- command_line = [environment, options[:shell] || "sh", "-c", "\"#{escaped}\""].compact.join(" ")
89
+ cmd = replace_placeholders(command, ch)
90
+
91
+ if options[:shell] == false
92
+ shell = nil
93
+ else
94
+ shell = "#{options[:shell] || "sh"} -c"
95
+ cmd = cmd.gsub(/[$\\`"]/) { |m| "\\#{m}" }
96
+ cmd = "\"#{cmd}\""
97
+ end
98
+
99
+ command_line = [environment, shell, cmd].compact.join(" ")
100
+
92
101
  ch.exec(command_line)
93
102
  ch.send_data(options[:data]) if options[:data]
94
103
  end
95
104
 
96
- channel.on_failure do |ch|
97
- # just log it, don't actually raise an exception, since the
98
- # process method will see that the status is not zero and will
99
- # raise an exception then.
100
- logger.important "could not open channel", ch[:server] if logger
101
- ch.close
105
+ if options[:pty]
106
+ channel.request_pty(:want_reply => true)
107
+ channel.on_success(&execute_command)
108
+ channel.on_failure do |ch|
109
+ # just log it, don't actually raise an exception, since the
110
+ # process method will see that the status is not zero and will
111
+ # raise an exception then.
112
+ logger.important "could not open channel", ch[:server] if logger
113
+ ch.close
114
+ end
115
+ else
116
+ execute_command.call(channel)
102
117
  end
103
-
118
+
104
119
  channel.on_data do |ch, data|
105
120
  @callback[ch, :out, data] if @callback
106
121
  end
@@ -23,6 +23,7 @@ module Capistrano
23
23
  def initialize_with_invocation(*args) #:nodoc:
24
24
  initialize_without_invocation(*args)
25
25
  set :default_environment, {}
26
+ set :default_run_options, {}
26
27
  end
27
28
 
28
29
  # Invokes the given command. If a +via+ key is given, it will be used
@@ -69,7 +70,7 @@ module Capistrano
69
70
  as = options.delete(:as)
70
71
 
71
72
  user = as && "-u #{as}"
72
- command = [fetch(:sudo, "sudo"), user, command].compact.join(" ")
73
+ command = [fetch(:sudo, "sudo"), "-p '#{sudo_prompt}'", user, command].compact.join(" ")
73
74
 
74
75
  run(command, options, &sudo_behavior_callback(block))
75
76
  end
@@ -83,9 +84,9 @@ module Capistrano
83
84
  # was wrong, let's track which host prompted first and only allow
84
85
  # subsequent prompts from that host.
85
86
  prompt_host = nil
86
-
87
+
87
88
  Proc.new do |ch, stream, out|
88
- if out =~ /password:/i
89
+ if out =~ /^#{Regexp.escape(sudo_prompt)}/
89
90
  ch.send_data "#{self[:password]}\n"
90
91
  elsif out =~ /try again/
91
92
  if prompt_host.nil? || prompt_host == ch[:server]
@@ -110,7 +111,8 @@ module Capistrano
110
111
  # Otherwise, if the :default_shell key exists in the configuration,
111
112
  # it will be used. Otherwise, no :shell key is added.
112
113
  def add_default_command_options(options)
113
- options = options.dup
114
+ defaults = self[:default_run_options]
115
+ options = defaults.merge(options)
114
116
 
115
117
  env = self[:default_environment]
116
118
  env = env.merge(options[:env]) if options[:env]
@@ -121,6 +123,11 @@ module Capistrano
121
123
 
122
124
  options
123
125
  end
126
+
127
+ # Returns the prompt text to use with sudo
128
+ def sudo_prompt
129
+ fetch(:sudo_prompt, "sudo password: ")
130
+ end
124
131
  end
125
132
  end
126
133
  end
@@ -25,6 +25,28 @@ module Capistrano
25
25
  def instance=(config)
26
26
  Thread.current[:capistrano_configuration] = config
27
27
  end
28
+
29
+ # Used internally by Capistrano to track which recipes have been loaded
30
+ # via require, so that they may be successfully reloaded when require
31
+ # is called again.
32
+ def recipes_per_feature
33
+ @recipes_per_feature ||= {}
34
+ end
35
+
36
+ # Used internally to determine what the current "feature" being
37
+ # required is. This is used to track which files load which recipes
38
+ # via require.
39
+ def current_feature
40
+ Thread.current[:capistrano_current_feature]
41
+ end
42
+
43
+ # Used internally to specify the current file being required, so that
44
+ # any recipes loaded by that file can be remembered. This allows
45
+ # recipes loaded via require to be correctly reloaded in different
46
+ # Configuration instances in the same Ruby instance.
47
+ def current_feature=(feature)
48
+ Thread.current[:capistrano_current_feature] = feature
49
+ end
28
50
  end
29
51
 
30
52
  # The load paths used for locating recipe files.
@@ -33,6 +55,7 @@ module Capistrano
33
55
  def initialize_with_loading(*args) #:nodoc:
34
56
  initialize_without_loading(*args)
35
57
  @load_paths = [".", File.expand_path(File.join(File.dirname(__FILE__), "../recipes"))]
58
+ @loaded_features = []
36
59
  end
37
60
  private :initialize_with_loading
38
61
 
@@ -66,9 +89,11 @@ module Capistrano
66
89
  load_from_file(options[:file], options[:name])
67
90
 
68
91
  elsif options[:string]
92
+ remember_load(options) unless options[:reloading]
69
93
  instance_eval(options[:string], options[:name] || "<eval>")
70
94
 
71
95
  elsif options[:proc]
96
+ remember_load(options) unless options[:reloading]
72
97
  instance_eval(&options[:proc])
73
98
 
74
99
  else
@@ -80,12 +105,63 @@ module Capistrano
80
105
  # with the exception that it sets the receiver as the "current" configuration
81
106
  # so that third-party task bundles can include themselves relative to
82
107
  # that configuration.
108
+ #
109
+ # This is a bit more complicated than an initial review would seem to
110
+ # necessitate, but the use case that complicates things is this: An
111
+ # advanced user wants to embed capistrano, and needs to instantiate
112
+ # more than one capistrano configuration at a time. They also want each
113
+ # configuration to require a third-party capistrano extension. Using a
114
+ # naive require implementation, this would allow the first configuration
115
+ # to successfully load the third-party extension, but the require would
116
+ # fail for the second configuration because the extension has already
117
+ # been loaded.
118
+ #
119
+ # To work around this, we do a few things:
120
+ #
121
+ # 1. Each time a 'require' is invoked inside of a capistrano recipe,
122
+ # we remember the arguments (see "current_feature").
123
+ # 2. Each time a 'load' is invoked inside of a capistrano recipe, and
124
+ # "current_feature" is not nil (meaning we are inside of a pending
125
+ # require) we remember the options (see "remember_load" and
126
+ # "recipes_per_feature").
127
+ # 3. Each time a 'require' is invoked inside of a capistrano recipe,
128
+ # we check to see if this particular configuration has ever seen these
129
+ # arguments to require (see @loaded_features), and if not, we proceed
130
+ # as if the file had never been required. If the superclass' require
131
+ # returns false (meaning, potentially, that the file has already been
132
+ # required), then we look in the recipes_per_feature collection and
133
+ # load any remembered recipes from there.
134
+ #
135
+ # It's kind of a bear, but it works, and works transparently. Note that
136
+ # a simpler implementation would just muck with $", allowing files to be
137
+ # required multiple times, but that will cause warnings (and possibly
138
+ # errors) if the file to be required contains constant definitions and
139
+ # such, alongside (or instead of) capistrano recipe definitions.
83
140
  def require(*args) #:nodoc:
84
- original, self.class.instance = self.class.instance, self
85
- super
86
- ensure
87
- # restore the original, so that require's can be nested
88
- self.class.instance = original
141
+ # look to see if this specific configuration instance has ever seen
142
+ # these arguments to require before
143
+ if !@loaded_features.include?(args)
144
+ @loaded_features << args
145
+
146
+ begin
147
+ original_instance, self.class.instance = self.class.instance, self
148
+ original_feature, self.class.current_feature = self.class.current_feature, args
149
+
150
+ result = super
151
+ if !result # file has been required previously, load up the remembered recipes
152
+ list = self.class.recipes_per_feature[args] || []
153
+ list.each { |options| load(options.merge(:reloading => true)) }
154
+ end
155
+
156
+ return result
157
+ ensure
158
+ # restore the original, so that require's can be nested
159
+ self.class.instance = original_instance
160
+ self.class.current_feature = original_feature
161
+ end
162
+ else
163
+ return false
164
+ end
89
165
  end
90
166
 
91
167
  private
@@ -107,6 +183,16 @@ module Capistrano
107
183
 
108
184
  raise LoadError, "no such file to load -- #{file}"
109
185
  end
186
+
187
+ # If a file is being required, the options associated with loading a
188
+ # recipe are remembered in the recipes_per_feature archive under the
189
+ # name of the file currently being required.
190
+ def remember_load(options)
191
+ if self.class.current_feature
192
+ list = (self.class.recipes_per_feature[self.class.current_feature] ||= [])
193
+ list << options
194
+ end
195
+ end
110
196
  end
111
197
  end
112
198
  end
@@ -32,6 +32,12 @@ module Capistrano
32
32
  end
33
33
  private :initialize_with_namespaces
34
34
 
35
+ # Returns the top-level namespace (the one with no parent).
36
+ def top
37
+ return parent.top if parent
38
+ return self
39
+ end
40
+
35
41
  # Returns the fully-qualified name of this namespace, or nil if the
36
42
  # namespace is at the top-level.
37
43
  def fully_qualified_name
@@ -39,9 +39,14 @@ _cset(:real_revision) { source.local.query_revision(revision) { |cmd| with_e
39
39
  _cset(:strategy) { Capistrano::Deploy::Strategy.new(deploy_via, self) }
40
40
 
41
41
  _cset(:release_name) { set :deploy_timestamped, true; Time.now.utc.strftime("%Y%m%d%H%M%S") }
42
- _cset(:releases_path) { File.join(deploy_to, "releases") }
43
- _cset(:shared_path) { File.join(deploy_to, "shared") }
44
- _cset(:current_path) { File.join(deploy_to, "current") }
42
+
43
+ _cset :version_dir, "releases"
44
+ _cset :shared_dir, "shared"
45
+ _cset :current_dir, "current"
46
+
47
+ _cset(:releases_path) { File.join(deploy_to, version_dir) }
48
+ _cset(:shared_path) { File.join(deploy_to, shared_dir) }
49
+ _cset(:current_path) { File.join(deploy_to, current_dir) }
45
50
  _cset(:release_path) { File.join(releases_path, release_name) }
46
51
 
47
52
  _cset(:releases) { capture("ls -x #{releases_path}").split.sort }
@@ -224,21 +229,26 @@ namespace :deploy do
224
229
  abort "Please specify at least one file to update (via the FILES environment variable)" if files.empty?
225
230
 
226
231
  files.each do |file|
227
- put File.read(file), File.join(current_path, file)
232
+ content = File.open(file, "rb") { |f| f.read }
233
+ put content, File.join(current_path, file)
228
234
  end
229
235
  end
230
236
 
231
237
  desc <<-DESC
232
238
  Restarts your application. This works by calling the script/process/reaper \
233
- script under the current path. By default, this will be invoked via sudo, \
234
- but if you are in an environment where sudo is not an option, or is not \
235
- allowed, you can indicate that restarts should use `run' instead by \
236
- setting the `use_sudo' variable to false:
237
-
239
+ script under the current path.
240
+
241
+ By default, this will be invoked via sudo as the `app' user. If \
242
+ you wish to run it as a different user, set the :runner variable to \
243
+ that user. If you are in an environment where you can't use sudo, set \
244
+ the :use_sudo variable to false:
245
+
238
246
  set :use_sudo, false
239
247
  DESC
240
248
  task :restart, :roles => :app, :except => { :no_release => true } do
241
- invoke_command "#{current_path}/script/process/reaper", :via => run_method
249
+ as = fetch(:runner, "app")
250
+ via = fetch(:run_method, :sudo)
251
+ invoke_command "#{current_path}/script/process/reaper", :via => via, :as => as
242
252
  end
243
253
 
244
254
  desc <<-DESC
@@ -436,7 +446,8 @@ namespace :deploy do
436
446
  might not be supported on all SCM's.
437
447
  DESC
438
448
  task :default, :except => { :no_release => true } do
439
- system(source.local.log(current_revision))
449
+ from = source.next_revision(current_revision)
450
+ system(source.local.log(from))
440
451
  end
441
452
  end
442
453
 
@@ -34,6 +34,36 @@ module Capistrano
34
34
  self
35
35
  end
36
36
 
37
+ def match(command, expect, options={})
38
+ expect = Regexp.new(Regexp.escape(expect.to_s)) unless expect.is_a?(Regexp)
39
+
40
+ output_per_server = {}
41
+ try("#{command} ", options) do |ch, stream, out|
42
+ output_per_server[ch[:server]] ||= ''
43
+ output_per_server[ch[:server]] += out
44
+ end
45
+
46
+ # It is possible for some of these commands to return a status != 0
47
+ # (for example, rake --version exits with a 1). For this check we
48
+ # just care if the output matches, so we reset the success flag.
49
+ @success = true
50
+
51
+ errored_hosts = []
52
+ output_per_server.each_pair do |server, output|
53
+ next if output =~ expect
54
+ errored_hosts << server
55
+ end
56
+
57
+ if errored_hosts.any?
58
+ @hosts = errored_hosts.join(', ')
59
+ output = output_per_server[errored_hosts.first]
60
+ @message = "the output #{output.inspect} from #{command.inspect} did not match #{expect.inspect}"
61
+ @success = false
62
+ end
63
+
64
+ self
65
+ end
66
+
37
67
  def or(message)
38
68
  @message = message
39
69
  self
@@ -55,6 +85,7 @@ module Capistrano
55
85
  return unless @success # short-circuit evaluation
56
86
  configuration.run(command, options) do |ch,stream,out|
57
87
  warn "#{ch[:server]}: #{out}" if stream == :err
88
+ yield ch, stream, out if block_given?
58
89
  end
59
90
  rescue Capistrano::CommandError => e
60
91
  @success = false
@@ -0,0 +1,169 @@
1
+ require 'capistrano/recipes/deploy/scm/base'
2
+ require 'rexml/xpath'
3
+ require 'rexml/document'
4
+
5
+ module Capistrano
6
+ module Deploy
7
+ module SCM
8
+ # Accurev bridge for use by Capistrano. This implementation does not
9
+ # implement all features of a Capistrano SCM module. The ones that are
10
+ # left out are either exceedingly difficult to implement with Accurev
11
+ # or are considered bad form.
12
+ #
13
+ # When using this module in a project, the following variables are used:
14
+ # * :repository - This should match the depot that code lives in. If your code
15
+ # exists in a subdirectory, you can append the path depot.
16
+ # eg. foo-depot/bar_dir
17
+ # * :stream - The stream in the depot that code should be pulled from. If
18
+ # left blank, the depot stream will be used
19
+ # * :revision - Should be in the form 'stream/transaction'.
20
+ class Accurev < Base
21
+ include REXML
22
+ default_command 'accurev'
23
+
24
+ # Defines pseudo-revision value for the most recent changes to be deployed.
25
+ def head
26
+ "#{stream}/highest"
27
+ end
28
+
29
+ # Given an Accurev revision identifier, this method returns an identifier that
30
+ # can be used for later SCM calls. This returned identifier will not
31
+ # change as a result of further SCM activity.
32
+ def query_revision(revision)
33
+ internal_revision = InternalRevision.parse(revision)
34
+ return revision unless internal_revision.psuedo_revision?
35
+
36
+ logger.debug("Querying for real revision for #{internal_revision}")
37
+ rev_stream = internal_revision.stream
38
+
39
+ logger.debug("Determining what type of stream #{rev_stream} is...")
40
+ stream_xml = yield show_streams_for(rev_stream)
41
+ stream_doc = Document.new(stream_xml)
42
+ type = XPath.first(stream_doc, '//streams/stream/@type').value
43
+
44
+ case type
45
+ when 'snapshot'
46
+ InternalRevision.new(rev_stream, 'highest').to_s
47
+ else
48
+ logger.debug("Getting latest transaction id in #{rev_stream}")
49
+ # Doing another yield for a second Accurev call. Hopefully this is ok.
50
+ hist_xml = yield scm(:hist, '-ftx', '-s', rev_stream, '-t', 'now.1')
51
+ hist_doc = Document.new(hist_xml)
52
+ transaction_id = XPath.first(hist_doc, '//AcResponse/transaction/@id').value
53
+ InternalRevision.new(stream, transaction_id).to_s
54
+ end
55
+ end
56
+
57
+ # Pops a copy of the code for the specified Accurev revision identifier.
58
+ # The revision identifier is represented as a stream & transaction ID combo.
59
+ # Accurev can only pop a particular transaction if a stream is created on the server
60
+ # with a time basis of that transaction id. Therefore, we will create a stream with
61
+ # the required criteria and pop that.
62
+ def export(revision_id, destination)
63
+ revision = InternalRevision.parse(revision_id)
64
+ logger.debug("Exporting #{revision.stream}/#{revision.transaction_id} to #{destination}")
65
+
66
+ commands = [
67
+ change_or_create_stream("#{revision.stream}-capistrano-deploy", revision),
68
+ "mkdir -p #{destination}",
69
+ scm_quiet(:pop, "-Rv #{stream}", "-L #{destination}", "'/./#{subdir}'")
70
+ ]
71
+ if subdir
72
+ commands.push(
73
+ "mv #{destination}/#{subdir}/* #{destination}",
74
+ "rm -rf #{File.join(destination, subdir)}"
75
+ )
76
+ end
77
+ commands.join(' && ')
78
+ end
79
+
80
+ # Returns the command needed to show the changes that exist between the two revisions.
81
+ def log(from, to=head)
82
+ logger.info("Getting transactions between #{from} and #{to}")
83
+ from_rev = InternalRevision.parse(from)
84
+ to_rev = InternalRevision.parse(to)
85
+
86
+ [
87
+ scm(:hist, '-s', from_rev.stream, '-t', "#{to_rev.transaction_id}-#{from_rev.transaction_id}"),
88
+ "sed -e '/transaction #{from_rev.transaction_id}/ { Q }'"
89
+ ].join(' | ')
90
+ end
91
+
92
+ # Returns the command needed to show the diff between what is deployed and what is
93
+ # pending. Because Accurev can not do this task without creating some streams,
94
+ # two time basis streams will be created for the purposes of doing the diff.
95
+ def diff(from, to=head)
96
+ from = InternalRevision.parse(from)
97
+ to = InternalRevision.parse(to)
98
+
99
+ from_stream = "#{from.stream}-capistrano-diff-from"
100
+ to_stream = "#{to.stream}-capistrano-diff-to"
101
+
102
+ [
103
+ change_or_create_stream(from_stream, from),
104
+ change_or_create_stream(to_stream, to),
105
+ scm(:diff, '-v', from_stream, '-V', to_stream, '-a')
106
+ ].join(' && ')
107
+ end
108
+
109
+ private
110
+ def depot
111
+ repository.split('/')[0]
112
+ end
113
+
114
+ def stream
115
+ variable(:stream) || depot
116
+ end
117
+
118
+ def subdir
119
+ repository.split('/')[1..-1].join('/') unless repository.index('/').nil?
120
+ end
121
+
122
+ def change_or_create_stream(name, revision)
123
+ [
124
+ scm_quiet(:mkstream, '-b', revision.stream, '-s', name, '-t', revision.transaction_id),
125
+ scm_quiet(:chstream, '-b', revision.stream, '-s', name, '-t', revision.transaction_id)
126
+ ].join('; ')
127
+ end
128
+
129
+ def show_streams_for(stream)
130
+ scm :show, '-fx', '-s', stream, :streams
131
+ end
132
+
133
+ def scm_quiet(*args)
134
+ scm(*args) + (variable(:scm_verbose) ? '' : '&> /dev/null')
135
+ end
136
+
137
+ class InternalRevision
138
+ attr_reader :stream, :transaction_id
139
+
140
+ def self.parse(string)
141
+ match = /([^\/]+)(\/(.+)){0,1}/.match(string)
142
+ raise "Unrecognized revision identifier: #{string}" unless match
143
+
144
+ stream = match[1]
145
+ transaction_id = match[3] || 'highest'
146
+ InternalRevision.new(stream, transaction_id)
147
+ end
148
+
149
+ def initialize(stream, transaction_id)
150
+ @stream = stream
151
+ @transaction_id = transaction_id
152
+ end
153
+
154
+ def psuedo_revision?
155
+ @transaction_id == 'highest'
156
+ end
157
+
158
+ def to_s
159
+ "#{stream}/#{transaction_id}"
160
+ end
161
+
162
+ def ==(other)
163
+ (stream == other.stream) && (transaction_id == other.transaction_id)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end