diff --git a/.circleci/config.yml b/.circleci/config.yml index 1fb8fd40..f4f43c23 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -51,6 +51,9 @@ jobs: docker: - image: crystallang/crystal:latest steps: + - run: + name: Install mercurial + command: apt-get update && apt-get install mercurial -y - shards-make-test test-on-osx: @@ -60,14 +63,17 @@ jobs: - with-brew-cache: steps: - run: - name: Install Crystal - command: brew install crystal + name: Install Crystal and Mercurial + command: brew install crystal mercurial - shards-make-test test-on-nightly: docker: - image: crystallang/crystal:nightly steps: + - run: + name: Install mercurial + command: apt-get update && apt-get install mercurial -y - shards-make-test workflows: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314eba7f..a610fdb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,15 @@ jobs: git config --global column.ui always git config --global core.autocrlf false + - name: Install Python + uses: actions/setup-python@v2 + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install Mercurial + run: pip install mercurial + - name: Install Crystal uses: oprypin/install-crystal@v1 with: diff --git a/docs/shard.yml.adoc b/docs/shard.yml.adoc index 62f261dd..8c824f05 100644 --- a/docs/shard.yml.adoc +++ b/docs/shard.yml.adoc @@ -320,6 +320,21 @@ Extends the _git_ resolver, and acts exactly like it. + *Example:* _bitbucket: tom/library_ +*hg*:: + +A Mercurial repository URL (string). ++ +The URL may be [any protocol](https://www.mercurial-scm.org/repo/hg/help/clone) +supported by Mercurial, which includes SSH and HTTPS. ++ +The Merurial repository will be cloned, the list of versions (and associated +_shard.yml_) will be extracted from Mercurial tags (e.g., _v1.2.3_). ++ +One of the other attributes (_version_, _tag_, _branch_, _bookmark_ or _commit_) is +required. When missing, Shards will install the _@_ bookmark or _tip_. ++ +*Example:* _hg: https://hg.example.org/crystal-library_ + *version*:: A version requirement (string). + @@ -342,13 +357,17 @@ the _~>_ operator has a special meaning, best shown by example: -- *branch*:: - Install the specified branch of a git dependency (string). + Install the specified branch of a git dependency or the named branch + of a mercurial dependency (string). *commit*:: - Install the specified commit of a git dependency (string). + Install the specified commit of a git or mercurial dependency (string). *tag*:: - Install the specified tag of a git dependency (string). + Install the specified tag of a git or mercurial dependency (string). + +*bookmark*:: + Install the specified bookmark of a mercurial dependency (string). == Example: diff --git a/spec/support/factories.cr b/spec/support/factories.cr index d8ffbd6c..400cbc1a 100644 --- a/spec/support/factories.cr +++ b/spec/support/factories.cr @@ -83,6 +83,79 @@ def checkout_git_branch(project, branch) end end +def create_hg_repository(project, *versions) + Dir.cd(tmp_path) do + run "hg init #{Process.quote(project)}" + end + + Dir.mkdir(File.join(hg_path(project), "src")) + File.write(File.join(hg_path(project), "src", "#{project}.cr"), "module #{project.capitalize}\nend") + + Dir.cd(hg_path(project)) do + run "hg add #{Process.quote("src/#{project}.cr")}" + end + + versions.each { |version| create_hg_release project, version } +end + +def create_fork_hg_repository(project, upstream) + Dir.cd(tmp_path) do + run "hg clone #{Process.quote(hg_url(upstream))} #{Process.quote(project)}" + end +end + +def create_hg_version_commit(project, version, shard : Bool | NamedTuple = true) + Dir.cd(hg_path(project)) do + if shard + contents = shard.is_a?(NamedTuple) ? shard : nil + create_shard project, version, contents + end + Dir.cd(hg_path(project)) do + name = shard[:name]? if shard.is_a?(NamedTuple) + name ||= project + File.touch "src/#{name}.cr" + run "hg add #{Process.quote("src/#{name}.cr")}" + end + create_hg_commit project, "release: v#{version}" + end +end + +def create_hg_release(project, version, shard : Bool | NamedTuple = true) + create_hg_version_commit(project, version, shard) + create_hg_tag(project, "v#{version}") +end + +def create_hg_tag(project, version) + Dir.cd(hg_path(project)) do + run "hg tag -u #{Process.quote("Your Name ")} #{Process.quote(version)}" + end +end + +def create_hg_commit(project, message = "new commit") + Dir.cd(hg_path(project)) do + File.write("src/#{project}.cr", "# #{message}", mode: "a") + run "hg commit -u #{Process.quote("Your Name ")} -A -m #{Process.quote(message)}" + end +end + +def checkout_new_hg_bookmark(project, branch) + Dir.cd(hg_path(project)) do + run "hg bookmark #{Process.quote(branch)}" + end +end + +def checkout_new_hg_branch(project, branch) + Dir.cd(hg_path(project)) do + run "hg branch #{Process.quote(branch)}" + end +end + +def checkout_hg_rev(project, rev) + Dir.cd(hg_path(project)) do + run "hg update -C #{Process.quote(rev)}" + end +end + def create_shard(project, version, contents : NamedTuple? = nil) spec = {name: project, version: version, crystal: Shards.crystal_version} spec = spec.merge(contents) if contents @@ -119,6 +192,20 @@ def git_path(project) File.join(tmp_path, project.to_s) end +def hg_commits(project, rev = ".") + Dir.cd(hg_path(project)) do + run("hg log --template=#{Process.quote("{node}\n")} -r #{Process.quote(rev)}").strip.split('\n') + end +end + +def hg_url(project) + "file://#{Path[hg_path(project)].to_posix}" +end + +def hg_path(project) + File.join(tmp_path, project.to_s) +end + def rel_path(project) "../../spec/.repositories/#{project}" end diff --git a/spec/support/requirement.cr b/spec/support/requirement.cr index 7a7a8223..e308dbde 100644 --- a/spec/support/requirement.cr +++ b/spec/support/requirement.cr @@ -6,6 +6,14 @@ def commit(sha1) Shards::GitCommitRef.new(sha1) end +def hg_bookmark(name) + Shards::HgBookmarkRef.new(name) +end + +def hg_branch(name) + Shards::HgBranchRef.new(name) +end + def version(version) Shards::Version.new(version) end diff --git a/spec/unit/hg_resolver_spec.cr b/spec/unit/hg_resolver_spec.cr new file mode 100644 index 00000000..0063f815 --- /dev/null +++ b/spec/unit/hg_resolver_spec.cr @@ -0,0 +1,172 @@ +require "./spec_helper" + +private def resolver(name) + Shards::HgResolver.new(name, hg_url(name)) +end + +module Shards + # Allow overriding `source` for the specs + class HgResolver + def source=(@source) + end + end + + describe HgResolver do + before_each do + create_hg_repository "empty" + create_hg_commit "empty", "initial release" + + create_hg_repository "unreleased" + create_hg_version_commit "unreleased", "0.1.0" + checkout_new_hg_branch "unreleased", "branch" + create_hg_commit "unreleased", "testing" + checkout_hg_rev "unreleased", "default" + + create_hg_repository "unreleased-bm" + create_hg_version_commit "unreleased-bm", "0.1.0" + checkout_new_hg_bookmark "unreleased-bm", "branch" + create_hg_commit "unreleased-bm", "testing" + checkout_hg_rev "unreleased-bm", "default" + + create_hg_repository "library", "0.0.1", "0.1.0", "0.1.1", "0.1.2", "0.2.0" + + # Create a version tag not prefixed by 'v' which should be ignored + create_hg_tag "library", "99.9.9" + end + + it "normalizes github bitbucket gitlab sources" do + # don't normalise other domains + HgResolver.normalize_key_source("hg", "HTTPs://myhgserver.com/Repo").should eq({"hg", "HTTPs://myhgserver.com/Repo"}) + + # don't change protocol from ssh + HgResolver.normalize_key_source("hg", "ssh://hg@myhgserver.com/Repo").should eq({"hg", "ssh://hg@myhgserver.com/Repo"}) + end + + it "available releases" do + resolver("empty").available_releases.should be_empty + resolver("library").available_releases.should eq(versions ["0.0.1", "0.1.0", "0.1.1", "0.1.2", "0.2.0"]) + end + + it "latest version for ref" do + expect_raises(Shards::Error, "No shard.yml was found for shard \"empty\" at commit #{hg_commits(:empty)[0]}") do + resolver("empty").latest_version_for_ref(hg_branch "default") + end + expect_raises(Shards::Error, "No shard.yml was found for shard \"empty\" at commit #{hg_commits(:empty)[0]}") do + resolver("empty").latest_version_for_ref(nil) + end + resolver("unreleased").latest_version_for_ref(hg_branch "default").should eq(version "0.1.0+hg.commit.#{hg_commits(:unreleased)[0]}") + resolver("unreleased").latest_version_for_ref(hg_branch "branch").should eq(version "0.1.0+hg.commit.#{hg_commits(:unreleased, "branch")[0]}") + resolver("unreleased").latest_version_for_ref(nil).should eq(version "0.1.0+hg.commit.#{hg_commits(:unreleased)[0]}") + resolver("unreleased-bm").latest_version_for_ref(hg_branch "default").should eq(version "0.1.0+hg.commit.#{hg_commits("unreleased-bm")[0]}") + resolver("unreleased-bm").latest_version_for_ref(hg_bookmark "branch").should eq(version "0.1.0+hg.commit.#{hg_commits("unreleased-bm", "branch")[0]}") + resolver("unreleased-bm").latest_version_for_ref(nil).should eq(version "0.1.0+hg.commit.#{hg_commits("unreleased-bm")[0]}") + resolver("library").latest_version_for_ref(hg_branch "default").should eq(version "0.2.0+hg.commit.#{hg_commits(:library)[0]}") + resolver("library").latest_version_for_ref(nil).should eq(version "0.2.0+hg.commit.#{hg_commits(:library)[0]}") + expect_raises(Shards::Error, "Could not find branch foo for shard \"library\" in the repository #{hg_url(:library)}") do + resolver("library").latest_version_for_ref(hg_branch "foo") + end + end + + it "versions for" do + expect_raises(Shards::Error, "No shard.yml was found for shard \"empty\" at commit #{hg_commits(:empty)[0]}") do + resolver("empty").versions_for(Any) + end + resolver("library").versions_for(Any).should eq(versions ["0.0.1", "0.1.0", "0.1.1", "0.1.2", "0.2.0"]) + resolver("library").versions_for(VersionReq.new "~> 0.1.0").should eq(versions ["0.1.0", "0.1.1", "0.1.2"]) + resolver("library").versions_for(hg_branch "default").should eq(versions ["0.2.0+hg.commit.#{hg_commits(:library)[0]}"]) + resolver("unreleased").versions_for(hg_branch "default").should eq(versions ["0.1.0+hg.commit.#{hg_commits(:unreleased)[0]}"]) + resolver("unreleased").versions_for(Any).should eq(versions ["0.1.0+hg.commit.#{hg_commits(:unreleased)[0]}"]) + resolver("unreleased-bm").versions_for(hg_branch "default").should eq(versions ["0.1.0+hg.commit.#{hg_commits("unreleased-bm")[0]}"]) + resolver("unreleased-bm").versions_for(Any).should eq(versions ["0.1.0+hg.commit.#{hg_commits("unreleased-bm")[0]}"]) + end + + it "read spec for release" do + spec = resolver("library").spec(version "0.1.1") + spec.original_version.should eq(version "0.1.1") + spec.version.should eq(version "0.1.1") + end + + it "read spec for commit" do + version = version("0.2.0+hg.commit.#{hg_commits(:library)[0]}") + spec = resolver("library").spec(version) + spec.original_version.should eq(version "0.2.0") + spec.version.should eq(version) + end + + it "install" do + library = resolver("library") + + library.install_sources(version("0.1.2"), install_path("library")) + File.exists?(install_path("library", "src/library.cr")).should be_true + File.exists?(install_path("library", "shard.yml")).should be_true + Spec.from_file(install_path("library", "shard.yml")).version.should eq(version "0.1.2") + + library.install_sources(version("0.2.0"), install_path("library")) + Spec.from_file(install_path("library", "shard.yml")).version.should eq(version "0.2.0") + end + + it "install commit" do + library = resolver("library") + version = version "0.2.0+hg.commit.#{hg_commits(:library)[0]}" + library.install_sources(version, install_path("library")) + Spec.from_file(install_path("library", "shard.yml")).version.should eq(version "0.2.0") + end + + it "origin changed" do + library = HgResolver.new("library", hg_url("library")) + library.install_sources(version("0.1.2"), install_path("library")) + + # Change the origin in the cache repo to https://foss.heptapod.net/foo/bar + hgrc_path = File.join(library.local_path, ".hg", "hgrc") + hgrc = File.read(hgrc_path) + hgrc = hgrc.gsub(/(default\s*=\s*)([^\r\n]*)/, "\\1https://foss.heptapod.net/foo/bar") + File.write(hgrc_path, hgrc) + # + # All of these alternatives should not trigger origin as changed + same_origins = [ + "https://foss.heptapod.net/foo/bar", + "https://foss.heptapod.net:1234/foo/bar", + "http://foss.heptapod.net/foo/bar", + "ssh://foss.heptapod.net/foo/bar", + "hg://foss.heptapod.net/foo/bar", + "rsync://foss.heptapod.net/foo/bar", + "hg@foss.heptapod.net:foo/bar", + "bob@foss.heptapod.net:foo/bar", + "foss.heptapod.net:foo/bar", + ] + + same_origins.each do |origin| + library.source = origin + library.origin_changed?.should be_false + end + + # These alternatives should all trigger origin as changed + changed_origins = [ + "https://foss.heptapod.net/foo/bar2", + "https://foss.heptapod.net/foos/bar", + "https://hghubz.com/foo/bar", + "file:///foss.heptapod.net/foo/bar", + "hg@foss.heptapod.net:foo/bar2", + "hg@foss.heptapod2.net.com:foo/bar", + "", + ] + + changed_origins.each do |origin| + library.source = origin + library.origin_changed?.should be_true + end + end + + it "renders report version" do + resolver("library").report_version(version "1.2.3").should eq("1.2.3") + resolver("library").report_version(version "1.2.3+hg.commit.654875c9dbfa8d72fba70d65fd548d51ffb85aff").should eq("1.2.3 at 654875c") + end + + it "#matches_ref" do + resolver = HgResolver.new("", "") + resolver.matches_ref?(HgCommitRef.new("1234567890abcdef"), Shards::Version.new("0.1.0.+hg.commit.1234567")).should be_true + resolver.matches_ref?(HgCommitRef.new("1234567890abcdef"), Shards::Version.new("0.1.0.+hg.commit.1234567890abcdef")).should be_true + resolver.matches_ref?(HgCommitRef.new("1234567"), Shards::Version.new("0.1.0.+hg.commit.1234567890abcdef")).should be_true + end + end +end diff --git a/src/config.cr b/src/config.cr index fa7cd190..51802006 100644 --- a/src/config.cr +++ b/src/config.cr @@ -12,6 +12,7 @@ module Shards VERSION_REFERENCE = /^v?\d+[-.][-.a-zA-Z\d]+$/ VERSION_TAG = /^v(\d+[-.][-.a-zA-Z\d]+)$/ VERSION_AT_GIT_COMMIT = /^(\d+[-.][-.a-zA-Z\d]+)\+git\.commit\.([0-9a-f]+)$/ + VERSION_AT_HG_COMMIT = /^(\d+[-.][-.a-zA-Z\d]+)\+hg\.commit\.([0-9a-f]+)$/ def self.cache_path @@cache_path ||= find_or_create_cache_path diff --git a/src/resolvers/hg.cr b/src/resolvers/hg.cr new file mode 100644 index 00000000..8887db80 --- /dev/null +++ b/src/resolvers/hg.cr @@ -0,0 +1,478 @@ +require "uri" +require "./resolver" +require "../versions" +require "../logger" +require "../helpers" + +module Shards + abstract struct HgRef < Ref + def full_info + to_s + end + end + + struct HgBranchRef < HgRef + def initialize(@branch : String) + end + + def to_hg_ref + @branch + end + + def to_hg_revset + "branch(\"#{@branch}\") and head()" + end + + def to_s(io) + io << "branch " << @branch + end + + def to_yaml(yaml) + yaml.scalar "branch" + yaml.scalar @branch + end + end + + struct HgBookmarkRef < HgRef + def initialize(@bookmark : String) + end + + def to_hg_ref + @bookmark + end + + def to_hg_revset + "bookmark(\"#{@bookmark}\")" + end + + def to_s(io) + io << "bookmark " << @bookmark + end + + def to_yaml(yaml) + yaml.scalar "bookmark" + yaml.scalar @bookmark + end + end + + struct HgTagRef < HgRef + def initialize(@tag : String) + end + + def to_hg_ref + @tag + end + + def to_hg_revset + "tag(\"#{@tag}\")" + end + + def to_s(io) + io << "tag " << @tag + end + + def to_yaml(yaml) + yaml.scalar "tag" + yaml.scalar @tag + end + end + + struct HgCommitRef < HgRef + getter commit : String + + def initialize(@commit : String) + end + + def =~(other : HgCommitRef) + commit.starts_with?(other.commit) || other.commit.starts_with?(commit) + end + + def to_hg_ref + @commit + end + + def to_hg_revset + @commit + end + + def to_s(io) + io << "commit " << @commit[0...7] + end + + def full_info + "commit #{@commit}" + end + + def to_yaml(yaml) + yaml.scalar "commit" + yaml.scalar @commit + end + end + + struct HgCurrentRef < HgRef + def to_hg_revset + "." + end + + def to_hg_ref + "." + end + + def to_s(io) + io << "current" + end + + def to_yaml(yaml) + raise NotImplementedError.new("HgCurrentRef is for internal use only") + end + end + + class HgResolver < Resolver + @@has_hg_command : Bool? + @@hg_version : String? + + @origin_url : String? + + def self.key + "hg" + end + + def self.normalize_key_source(key : String, source : String) : {String, String} + case key + when "hg" + {"hg", source} + else + raise "Unknown resolver #{key}" + end + end + + protected def self.has_hg_command? + if @@has_hg_command.nil? + @@has_hg_command = (Process.run("hg", ["--version"]).success? rescue false) + end + @@has_hg_command + end + + protected def self.hg_version + @@hg_version ||= `hg --version`[/\(version\s+([^)]*)\)/, 1] + end + + def read_spec(version : Version) : String? + update_local_cache + ref = hg_ref(version) + + if file_exists?(ref, SPEC_FILENAME) + capture("hg cat -r #{Process.quote(ref.to_hg_revset)} #{Process.quote(SPEC_FILENAME)}") + else + Log.debug { "Missing \"#{SPEC_FILENAME}\" for #{name.inspect} at #{ref}" } + nil + end + end + + private def spec_at_ref(ref : HgRef) : Spec? + update_local_cache + begin + if file_exists?(ref, SPEC_FILENAME) + spec_yaml = capture("hg cat -r #{Process.quote(ref.to_hg_revset)} #{Process.quote(SPEC_FILENAME)}") + Spec.from_yaml(spec_yaml) + end + rescue Error + nil + end + end + + private def spec?(version) + spec(version) + rescue Error + end + + def available_releases : Array(Version) + update_local_cache + versions_from_tags + end + + def latest_version_for_ref(ref : HgRef?) : Version + update_local_cache + ref ||= HgCurrentRef.new + begin + commit = commit_sha1_at(ref) + rescue Error + raise Error.new "Could not find #{ref.full_info} for shard #{name.inspect} in the repository #{source}" + end + + if spec = spec_at_ref(ref) + Version.new "#{spec.version.value}+hg.commit.#{commit}" + else + raise Error.new "No #{SPEC_FILENAME} was found for shard #{name.inspect} at commit #{commit}" + end + end + + def matches_ref?(ref : HgRef, version : Version) + case ref + when HgCommitRef + ref =~ hg_ref(version) + when HgBranchRef, HgBookmarkRef, HgCurrentRef + # TODO: check if version is the branch + version.has_metadata? + else + # TODO: check branch and tags + true + end + end + + protected def versions_from_tags + capture("hg tags --template #{Process.quote("{tag}\n")}") + .lines + .sort! + .compact_map { |tag| Version.new($1) if tag =~ VERSION_TAG } + end + + def install_sources(version : Version, install_path : String) + update_local_cache + ref = hg_ref(version) + + FileUtils.rm_r(install_path) if File.exists?(install_path) + Dir.mkdir_p(install_path) + run "hg clone --quiet -u #{Process.quote(ref.to_hg_ref)} -- #{Process.quote(local_path)} #{Process.quote(install_path)}" + end + + def commit_sha1_at(ref : HgRef) + capture("hg log -r #{Process.quote(ref.to_hg_revset)} --template #{Process.quote("{node}\n")}").strip + end + + def local_path + @local_path ||= begin + uri = parse_uri(hg_url) + + path = uri.path + path = Path[path] + # E.g. turns "c:\local\path" into "c\local\path". Or just drops the leading slash. + if (anchor = path.anchor) + path = Path[path.drive.to_s.rchop(":"), path.relative_to(anchor)] + end + + if host = uri.host + File.join(Shards.cache_path, host, path) + else + File.join(Shards.cache_path, path) + end + end + end + + def hg_url + source.strip + end + + def parse_requirement(params : Hash(String, String)) : Requirement + params.each do |key, value| + case key + when "branch" + return HgBranchRef.new value + when "bookmark" + return HgBookmarkRef.new value + when "tag" + return HgTagRef.new value + when "commit" + return HgCommitRef.new value + end + end + + super + end + + record HgVersion, value : String, commit : String? = nil + + private def parse_hg_version(version : Version) : HgVersion + case version.value + when VERSION_REFERENCE + HgVersion.new version.value + when VERSION_AT_HG_COMMIT + HgVersion.new $1, $2 + else + raise Error.new("Invalid version for hg resolver: #{version}") + end + end + + private def hg_ref(version : Version) : HgRef + hg_version = parse_hg_version(version) + if commit = hg_version.commit + HgCommitRef.new commit + else + HgTagRef.new "v#{hg_version.value}" + end + end + + private def update_local_cache + if cloned_repository? && origin_changed? + delete_repository + @updated_cache = false + end + + return if Shards.local? || @updated_cache + Log.info { "Fetching #{hg_url}" } + + if cloned_repository? + # repositories cloned with shards v0.8.0 won't fetch any new remote + # refs; we must delete them and clone again! + if valid_repository? + fetch_repository + else + delete_repository + mirror_repository + end + else + mirror_repository + end + + @updated_cache = true + end + + private def mirror_repository + path = local_path + FileUtils.rm_r(path) if File.exists?(path) + Dir.mkdir_p(path) + + source = hg_url + # Remove a "file://" from the beginning, otherwise the path might be invalid + # on Windows. + source = source.lchop("file://") + + hg_retry(err: "Failed to clone #{source}") do + # We checkout the working directory so that "." is meaningful. + # + # An alternative would be to use the `@` bookmark, but only as long + # as nothing new is committed. + run_in_current_folder "hg clone --quiet -- #{Process.quote(source)} #{Process.quote(path)}" + end + end + + private def fetch_repository + hg_retry(err: "Failed to update #{hg_url}") do + run "hg pull" + end + end + + private def hg_retry(err = "Failed to update repository") + retries = 0 + loop do + return yield + rescue ex : Error + retries += 1 + next if retries < 3 + raise Error.new("#{err}: #{ex}") + end + end + + private def delete_repository + Log.debug { "rm -rf #{Process.quote(local_path)}" } + Shards::Helpers.rm_rf(local_path) + @origin_url = nil + end + + private def cloned_repository? + Dir.exists?(local_path) + end + + private def valid_repository? + File.exists?(File.join(local_path, ".hg", "dirstate")) + end + + private def origin_url + @origin_url ||= capture("hg paths default").strip + end + + # Returns whether origin URLs have differing hosts and/or paths. + protected def origin_changed? + return false if origin_url == hg_url + return true if origin_url.nil? || hg_url.nil? + + origin_parsed = parse_uri(origin_url) + hg_parsed = parse_uri(hg_url) + + (origin_parsed.host != hg_parsed.host) || (origin_parsed.path != hg_parsed.path) + end + + # Parses a URI string, with additional support for ssh+git URI schemes. + private def parse_uri(raw_uri) + # Need to check for file URIs early, otherwise generic parsing will fail on a colon. + if (path = raw_uri.lchop?("file://")) + return URI.new(scheme: "file", path: path) + end + + # Try normal URI parsing first + uri = URI.parse(raw_uri) + return uri if uri.absolute? && !uri.opaque? + + # Otherwise, assume and attempt to parse the scp-style ssh URIs + host, _, path = raw_uri.partition(':') + + if host.includes?('@') + user, _, host = host.partition('@') + end + + # Normalize leading slash, matching URI parsing + unless path.starts_with?('/') + path = '/' + path + end + + URI.new(scheme: "ssh", host: host, path: path, user: user) + end + + private def file_exists?(ref : HgRef, path) + run("hg files -r #{Process.quote(ref.to_hg_revset)} -- #{Process.quote(path)}", raise_on_fail: false) + end + + private def capture(command, path = local_path) + run(command, capture: true, path: path).as(String) + end + + private def run(command, path = local_path, capture = false, raise_on_fail = true) + if Shards.local? && !Dir.exists?(path) + dependency_name = File.basename(path) + raise Error.new("Missing repository cache for #{dependency_name.inspect}. Please run without --local to fetch it.") + end + Dir.cd(path) do + run_in_current_folder(command, capture, raise_on_fail: raise_on_fail) + end + end + + private def run_in_current_folder(command, capture = false, raise_on_fail = true) + unless HgResolver.has_hg_command? + raise Error.new("Error missing hg command line tool. Please install Mercurial first!") + end + + Log.debug { command } + + output = capture ? IO::Memory.new : Process::Redirect::Close + error = IO::Memory.new + status = Process.run(command, shell: true, output: output, error: error) + + if status.success? + if capture + output.to_s + else + true + end + elsif raise_on_fail + str = error.to_s + if str.starts_with?("abort: ") && (idx = str.index('\n')) + message = str[7...idx] + else + message = str + end + raise Error.new("Failed #{command} (#{message}). Maybe a commit, branch, bookmark or file doesn't exist?") + end + end + + def report_version(version : Version) : String + hg_version = parse_hg_version(version) + if commit = hg_version.commit + "#{hg_version.value} at #{commit[0...7]}" + else + version.value + end + end + + register_resolver "hg", HgResolver + end +end