lib/braid/mirror.rb in braid-1.0.22 vs lib/braid/mirror.rb in braid-1.1.0

- old
+ new

@@ -1,8 +1,11 @@ module Braid class Mirror - ATTRIBUTES = %w(url branch revision tag path) + # Since Braid 1.1.0, the attributes are written to .braids.json in this + # canonical order. For now, the order is chosen to match what Braid 1.0.22 + # produced for newly added mirrors. + ATTRIBUTES = %w(url branch path tag revision) class UnknownType < BraidError def message "unknown type: #{super}" end @@ -20,24 +23,59 @@ include Operations::VersionControl attr_reader :path, :attributes - def initialize(path, attributes = {}) + def initialize(path, attributes = {}, breaking_change_cb = DUMMY_BREAKING_CHANGE_CB) @path = path.sub(/\/$/, '') - @attributes = attributes + @attributes = attributes.dup + + # Not that it's terribly important to check for such an old feature. This + # is mainly to demonstrate the RemoveMirrorDueToBreakingChange mechanism + # in case we want to use it for something else in the future. + if !@attributes['type'].nil? && @attributes['type'] != 'git' + breaking_change_cb.call <<-DESC +- Mirror '#{path}' is of a Subversion repository, which is no + longer supported. The mirror will be removed from your configuration, leaving + the data in the tree. +DESC + raise Config::RemoveMirrorDueToBreakingChange + end + @attributes.delete('type') + + # Migrate revision locks from Braid < 1.0.18. We no longer store the + # original branch or tag (the user has to specify it again when + # unlocking); we simply represent a locked revision by the absence of a + # branch or tag. + if @attributes['lock'] + @attributes.delete('lock') + @attributes['branch'] = nil + @attributes['tag'] = nil + end + + # Removal of support for full-history mirrors from Braid < 1.0.17 is a + # breaking change for users who wanted to use the imported history in some + # way. + if !@attributes['squashed'].nil? && @attributes['squashed'] != true + breaking_change_cb.call <<-DESC +- Mirror '#{path}' is full-history, which is no longer supported. + It will be changed to squashed. Upstream history already imported will remain + in your project's history and will have no effect on Braid. +DESC + end + @attributes.delete('squashed') end def self.new_from_options(url, options = {}) url = url.sub(/\/$/, '') raise NoTagAndBranch if options['tag'] && options['branch'] tag = options['tag'] branch = options['branch'] || (tag.nil? ? 'master' : nil) - path = (options['path'] || extract_path_from_url(url)).sub(/\/$/, '') + path = (options['path'] || extract_path_from_url(url, options['remote_path'])).sub(/\/$/, '') raise PathRequired unless path remote_path = options['remote_path'] attributes = {'url' => url, 'branch' => branch, 'path' => remote_path, 'tag' => tag} @@ -57,19 +95,72 @@ # `test z$(git merge-base A B) = z$(git rev-parse --verify A)` commit = git.rev_parse(commit) !!base_revision && git.merge_base(commit, base_revision) == commit end - def versioned_path(revision) - "#{revision}:#{self.remote_path}" + def upstream_item_for_revision(revision) + git.get_tree_item(revision, self.remote_path) end + # Return the arguments that should be passed to "git diff" to diff this + # mirror (including uncommitted changes by default), incorporating the given + # user-specified arguments. Having the caller run "git diff" is convenient + # for now but violates encapsulation a little; we may have to reorganize the + # code in order to add features. + def diff_args(user_args = []) + upstream_item = upstream_item_for_revision(base_revision) + + # We do not need to spend the time to copy the content outside the + # mirror from HEAD because --relative will exclude it anyway. Rename + # detection seems to apply only to the files included in the diff, so we + # shouldn't have another bug like + # https://github.com/cristibalan/braid/issues/41. + base_tree = git.make_tree_with_item(nil, path, upstream_item) + + # Note: --relative does a naive prefix comparison. If we set (for + # example) `--relative=a/b`, that will match an unrelated file or + # directory name `a/bb`. If the mirror is a directory, we can avoid this + # by adding a trailing slash to the prefix. + # + # If the mirror is a file, the only way we can avoid matching a path like + # `a/bb` is to pass a path argument to limit the diff. This means if the + # user passes additional path arguments, we won't get the behavior we + # expect, which is the intersection of the user-specified paths with the + # mirror. However, it's probably unreasonable for a user to pass path + # arguments when diffing a single-file mirror, so we ignore the issue. + # + # Note: This code doesn't handle various cases in which a directory at the + # root of a mirror turns into a file or vice versa. If that happens, + # hopefully the user takes corrective action manually. + if upstream_item.is_a?(git.BlobWithMode) + # For a single-file mirror, we use the upstream basename for the + # upstream side of the diff and the downstream basename for the + # downstream side, like what `git diff` does when given two blobs as + # arguments. Use --relative to strip away the entire downstream path + # before we add the basenames. + return [ + '--relative=' + path, + '--src-prefix=a/' + File.basename(remote_path), + '--dst-prefix=b/' + File.basename(path), + base_tree, + # user_args may contain options, which must come before paths. + *user_args, + path + ] + else + return [ + '--relative=' + path + '/', + base_tree, + *user_args + ] + end + end + + # Precondition: the remote for this mirror is set up. def diff fetch_base_revision_if_missing - remote_hash = git.rev_parse(versioned_path(base_revision)) - local_hash = git.tree_hash(path) - remote_hash != local_hash ? git.diff_tree(remote_hash, local_hash) : '' + git.diff(diff_args) end # Re-fetching the remote after deleting and re-adding it may be slow even if # all objects are still present in the repository # (https://github.com/cristibalan/braid/issues/71). Mitigate this for @@ -77,11 +168,11 @@ # if the base revision is already present in the repository. def fetch_base_revision_if_missing begin # Without ^{commit}, this will happily pass back an object hash even if # the object isn't present. See the git-rev-parse(1) man page. - git.rev_parse(base_revision + "^{commit}") + git.rev_parse(base_revision + '^{commit}') rescue Operations::UnknownRevision fetch end end @@ -128,10 +219,15 @@ "#{branch || tag || 'revision'}/braid/#{path}" end private + DUMMY_BREAKING_CHANGE_CB = lambda { |desc| + raise InternalError, 'Instantiated a mirror using an unsupported ' + + 'feature outside of configuration loading.' + } + def method_missing(name, *args) if ATTRIBUTES.find { |attribute| name.to_s =~ /^(#{attribute})(=)?$/ } if $2 attributes[$1] = args[0] else @@ -157,10 +253,14 @@ end end hash end - def self.extract_path_from_url(url) + def self.extract_path_from_url(url, remote_path) + if remote_path + return File.basename(remote_path) + end + return nil unless url name = File.basename(url) if File.extname(name) == '.git' # strip .git