capistrano 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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