From 2485428fe0e199af2d7bb01a0528b7d8e9535ff4 Mon Sep 17 00:00:00 2001 From: Alexa Date: Thu, 28 Oct 2021 00:51:56 -0600 Subject: [PATCH 01/23] Initial work on Fossil resolver --- src/config.cr | 1 + src/resolvers/fossil.cr | 447 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 src/resolvers/fossil.cr diff --git a/src/config.cr b/src/config.cr index 51802006..8ee8db28 100644 --- a/src/config.cr +++ b/src/config.cr @@ -13,6 +13,7 @@ module Shards 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]+)$/ + VERSION_AT_FOSSIL_COMMIT = /^(\d+[-.][-.a-zA-Z\d]+)\+fossil\.commit\.([0-9a-f]+)$/ def self.cache_path @@cache_path ||= find_or_create_cache_path diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr new file mode 100644 index 00000000..b208aed2 --- /dev/null +++ b/src/resolvers/fossil.cr @@ -0,0 +1,447 @@ +require "uri" +require "./resolver" +require "../versions" +require "../logger" +require "../helpers" + +module Shards + abstract struct FossilRef < Ref + def full_info + to_s + end + end + + struct FossilBranchRef < FossilRef + def initialize(@branch : String) + end + + def to_fossil_ref + @branch + end + + def to_s(io) + io << "branch " << @branch + end + + def to_yaml(yaml) + yaml.scalar "branch" + yaml.scalar @branch + end + end + + struct FossilTagRef < FossilRef + def initialize(@tag : String) + end + + def to_fossil_ref + @tag + end + + def to_s(io) + io << "tag " << @tag + end + + def to_yaml(yaml) + yaml.scalar "tag" + yaml.scalar @tag + end + end + + struct FossilCommitRef < FossilRef + getter commit : String + + def initialize(@commit : String) + end + + def =~(other : FossilCommitRef) + commit.starts_with?(other.commit) || other.commit.starts_with?(commit) + end + + def to_fossil_ref + @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 FossilTipRef < FossilRef + def to_fossil_ref + "tip" + end + + def to_s(io) + io << "tip" + end + + def to_yaml(yaml) + raise NotImplementedError.new("FossilTipRef is for internal use only") + end + end + + class FossilResolver < Resolver + @@has_fossil_command : Bool? + @@fossil_version : String? + + @origin_url : String? + @local_fossil_file : String? + + def self.key + "fossil" + end + + def self.normalize_key_source(key : String, source : String) : {String, String} + case key + when "fossil" + {"fossil", source} + else + raise "Unknown resolver #{key}" + end + end + + protected def self.has_fossil_command? + if @@has_fossil_command.nil? + @@has_fossil_command = (Process.run("fossil version", shell: true).success? rescue false) + end + @@has_fossil_command + end + + protected def self.fossil_version + @@fossil_version ||= `fossil version`[/version\s+([^\s]*)/, 1] + end + + def read_spec(version : Version) : String? + update_local_cache + ref = fossil_ref(version) + + if file_exists?(ref, SPEC_FILENAME) + capture("fossil cat -R #{Process.quote(local_fossil_file)} #{Process.quote(SPEC_FILENAME)} -r #{Process.quote(ref.to_fossil_ref)}") + else + Log.debug { "Missing \"#{SPEC_FILENAME}\" for #{name.inspect} at #{ref}" } + nil + end + end + + private def spec_at_ref(ref : FossilRef, commit) : Spec + update_local_cache + + unless file_exists?(ref, SPEC_FILENAME) + raise Error.new "No #{SPEC_FILENAME} was found for shard #{name.inspect} at commit #{commit}" + end + + spec_yaml = capture("fossil cat -R #{Process.quote(local_fossil_file)} #{Process.quote(SPEC_FILENAME)} -r #{Process.quote(ref.to_fossil_ref)}") + begin + Spec.from_yaml(spec_yaml) + rescue error : Error + raise Error.new "Invalid #{SPEC_FILENAME} for shard #{name.inspect} at commit #{commit}: #{error.message}" + 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 : FossilRef?) : Version + update_local_cache + ref ||= FossilTipRef.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 + + spec = spec_at_ref(ref, commit) + Version.new "#{spec.version.value}+fossil.commit.#{commit}" + end + + def matches_ref?(ref : FossilRef, version : Version) + case ref + when FossilCommitRef + ref =~ fossil_ref(version) + when FossilBranchRef, FossilTipRef + # 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("fossil tag list -R #{Process.quote(local_fossil_file)}") + .split('\n') + .compact_map { |tag| Version.new($1) if tag =~ VERSION_TAG } + end + + def install_sources(version : Version, install_path : String) + update_local_cache + ref = fossil_ref(version) + + FileUtils.rm_r(install_path) if File.exists?(install_path) + Dir.mkdir_p(install_path) + Log.debug { "Local path: #{local_path}" } + Log.debug { "Install path: #{install_path}" } + + install_fossil_file = Path[install_path].join("..", "#{name}.fossil").normalize.to_s + #run "fossil clone #{Process.quote(local_fossil_file)} #{install_fossil_file}" + run "fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + end + + def commit_sha1_at(ref : FossilRef) + capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -n 1 -F %H -R #{Process.quote(local_fossil_file)}").strip.lines[0] + end + + def local_path + @local_path ||= begin + uri = parse_uri(fossil_url) + + path = uri.path + path = Path[path] + # E.g. turns "c:\local\path.git" into "c\local\path.git". 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 local_fossil_file + @local_fossil_file ||= Path[local_path].join("..", "#{name}.fossil").normalize.to_s + end + + def fossil_url + source.strip + end + + def parse_requirement(params : Hash(String, String)) : Requirement + params.each do |key, value| + case key + when "branch" + return FossilBranchRef.new value + when "tag" + return FossilTagRef.new value + when "commit" + return FossilCommitRef.new value + else + end + end + + super + end + + record FossilVersion, value : String, commit : String? = nil + + private def parse_fossil_version(version : Version) : FossilVersion + case version.value + when VERSION_REFERENCE + FossilVersion.new version.value + when VERSION_AT_FOSSIL_COMMIT + FossilVersion.new $1, $2 + else + raise Error.new("Invalid version for fossil resolver: #{version}") + end + end + + private def fossil_ref(version : Version) : FossilRef + fossil_version = parse_fossil_version(version) + if commit = fossil_version.commit + FossilCommitRef.new commit + else + FossilTagRef.new "v#{fossil_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 #{fossil_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 = fossil_url + # Remove a "file://" from the beginning, otherwise the path might be invalid + # on Windows. + source = source.lchop("file://") + + Log.debug { "Local path: #{local_path}" } + fossil_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 "fossil clone #{Process.quote(source)} #{Process.quote(path)}.fossil" + end + end + + private def fetch_repository + fossil_retry(err: "Failed to update #{fossil_url}") do + run "fossil pull -R #{Process.quote(local_fossil_file)}" + end + end + + private def fossil_retry(err = "Failed to fetch repository") + retries = 0 + loop do + yield + break + rescue Error + retries += 1 + next if retries < 3 + raise Error.new(err) + 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.each_line(File.join(local_path, "config")) do |line| + return true if line =~ /mirror\s*=\s*true/ + end + false + end + + private def origin_url + @origin_url ||= capture("fossil remote-url -R #{Process.quote(local_fossil_file)}").strip + end + + # Returns whether origin URLs have differing hosts and/or paths. + protected def origin_changed? + return false if origin_url == fossil_url + return true if origin_url.nil? || fossil_url.nil? + + origin_parsed = parse_uri(origin_url) + fossil_parsed = parse_uri(fossil_url) + + (origin_parsed.host != fossil_parsed.host) || (origin_parsed.path != fossil_parsed.path) + end + + # Parses a URI string + 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 : FossilRef, path) + files = capture("fossil ls -R #{Process.quote(local_fossil_file)} -r #{Process.quote(ref.to_fossil_ref)} #{Process.quote(path)}") + !files.strip.empty? + end + + private def capture(command, path = local_path) + run(command, capture: true, path: path).not_nil! + end + + private def run(command, path = local_path, capture = false) + if Shards.local? && !Dir.exists?(path) + dependency_name = File.basename(path, ".fossil") + 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) + end + end + + private def run_in_current_folder(command, capture = false) + unless FossilResolver.has_fossil_command? + raise Error.new("Error missing fossil command line tool. Please install Fossil first!") + end + + Log.debug { command } + + STDERR.flush + 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? + output.to_s if capture + else + message = error.to_s + Log.debug { caller.join("\n => ") } + raise Error.new("Failed #{command} (#{message}). Maybe a commit, branch or file doesn't exist?") + end + end + + def report_version(version : Version) : String + fossil_version = parse_fossil_version(version) + if commit = fossil_version.commit + "#{fossil_version.value} at #{commit[0...7]}" + else + version.value + end + end + + register_resolver "fossil", FossilResolver + end +end From 95c188d41de71b9f3a1a87c9cb4bac17833c82bb Mon Sep 17 00:00:00 2001 From: MistressRemilia Date: Thu, 28 Oct 2021 00:51:56 -0600 Subject: [PATCH 02/23] Initial work on Fossil resolver --- src/config.cr | 1 + src/resolvers/fossil.cr | 447 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 src/resolvers/fossil.cr diff --git a/src/config.cr b/src/config.cr index 51802006..8ee8db28 100644 --- a/src/config.cr +++ b/src/config.cr @@ -13,6 +13,7 @@ module Shards 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]+)$/ + VERSION_AT_FOSSIL_COMMIT = /^(\d+[-.][-.a-zA-Z\d]+)\+fossil\.commit\.([0-9a-f]+)$/ def self.cache_path @@cache_path ||= find_or_create_cache_path diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr new file mode 100644 index 00000000..b208aed2 --- /dev/null +++ b/src/resolvers/fossil.cr @@ -0,0 +1,447 @@ +require "uri" +require "./resolver" +require "../versions" +require "../logger" +require "../helpers" + +module Shards + abstract struct FossilRef < Ref + def full_info + to_s + end + end + + struct FossilBranchRef < FossilRef + def initialize(@branch : String) + end + + def to_fossil_ref + @branch + end + + def to_s(io) + io << "branch " << @branch + end + + def to_yaml(yaml) + yaml.scalar "branch" + yaml.scalar @branch + end + end + + struct FossilTagRef < FossilRef + def initialize(@tag : String) + end + + def to_fossil_ref + @tag + end + + def to_s(io) + io << "tag " << @tag + end + + def to_yaml(yaml) + yaml.scalar "tag" + yaml.scalar @tag + end + end + + struct FossilCommitRef < FossilRef + getter commit : String + + def initialize(@commit : String) + end + + def =~(other : FossilCommitRef) + commit.starts_with?(other.commit) || other.commit.starts_with?(commit) + end + + def to_fossil_ref + @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 FossilTipRef < FossilRef + def to_fossil_ref + "tip" + end + + def to_s(io) + io << "tip" + end + + def to_yaml(yaml) + raise NotImplementedError.new("FossilTipRef is for internal use only") + end + end + + class FossilResolver < Resolver + @@has_fossil_command : Bool? + @@fossil_version : String? + + @origin_url : String? + @local_fossil_file : String? + + def self.key + "fossil" + end + + def self.normalize_key_source(key : String, source : String) : {String, String} + case key + when "fossil" + {"fossil", source} + else + raise "Unknown resolver #{key}" + end + end + + protected def self.has_fossil_command? + if @@has_fossil_command.nil? + @@has_fossil_command = (Process.run("fossil version", shell: true).success? rescue false) + end + @@has_fossil_command + end + + protected def self.fossil_version + @@fossil_version ||= `fossil version`[/version\s+([^\s]*)/, 1] + end + + def read_spec(version : Version) : String? + update_local_cache + ref = fossil_ref(version) + + if file_exists?(ref, SPEC_FILENAME) + capture("fossil cat -R #{Process.quote(local_fossil_file)} #{Process.quote(SPEC_FILENAME)} -r #{Process.quote(ref.to_fossil_ref)}") + else + Log.debug { "Missing \"#{SPEC_FILENAME}\" for #{name.inspect} at #{ref}" } + nil + end + end + + private def spec_at_ref(ref : FossilRef, commit) : Spec + update_local_cache + + unless file_exists?(ref, SPEC_FILENAME) + raise Error.new "No #{SPEC_FILENAME} was found for shard #{name.inspect} at commit #{commit}" + end + + spec_yaml = capture("fossil cat -R #{Process.quote(local_fossil_file)} #{Process.quote(SPEC_FILENAME)} -r #{Process.quote(ref.to_fossil_ref)}") + begin + Spec.from_yaml(spec_yaml) + rescue error : Error + raise Error.new "Invalid #{SPEC_FILENAME} for shard #{name.inspect} at commit #{commit}: #{error.message}" + 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 : FossilRef?) : Version + update_local_cache + ref ||= FossilTipRef.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 + + spec = spec_at_ref(ref, commit) + Version.new "#{spec.version.value}+fossil.commit.#{commit}" + end + + def matches_ref?(ref : FossilRef, version : Version) + case ref + when FossilCommitRef + ref =~ fossil_ref(version) + when FossilBranchRef, FossilTipRef + # 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("fossil tag list -R #{Process.quote(local_fossil_file)}") + .split('\n') + .compact_map { |tag| Version.new($1) if tag =~ VERSION_TAG } + end + + def install_sources(version : Version, install_path : String) + update_local_cache + ref = fossil_ref(version) + + FileUtils.rm_r(install_path) if File.exists?(install_path) + Dir.mkdir_p(install_path) + Log.debug { "Local path: #{local_path}" } + Log.debug { "Install path: #{install_path}" } + + install_fossil_file = Path[install_path].join("..", "#{name}.fossil").normalize.to_s + #run "fossil clone #{Process.quote(local_fossil_file)} #{install_fossil_file}" + run "fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + end + + def commit_sha1_at(ref : FossilRef) + capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -n 1 -F %H -R #{Process.quote(local_fossil_file)}").strip.lines[0] + end + + def local_path + @local_path ||= begin + uri = parse_uri(fossil_url) + + path = uri.path + path = Path[path] + # E.g. turns "c:\local\path.git" into "c\local\path.git". 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 local_fossil_file + @local_fossil_file ||= Path[local_path].join("..", "#{name}.fossil").normalize.to_s + end + + def fossil_url + source.strip + end + + def parse_requirement(params : Hash(String, String)) : Requirement + params.each do |key, value| + case key + when "branch" + return FossilBranchRef.new value + when "tag" + return FossilTagRef.new value + when "commit" + return FossilCommitRef.new value + else + end + end + + super + end + + record FossilVersion, value : String, commit : String? = nil + + private def parse_fossil_version(version : Version) : FossilVersion + case version.value + when VERSION_REFERENCE + FossilVersion.new version.value + when VERSION_AT_FOSSIL_COMMIT + FossilVersion.new $1, $2 + else + raise Error.new("Invalid version for fossil resolver: #{version}") + end + end + + private def fossil_ref(version : Version) : FossilRef + fossil_version = parse_fossil_version(version) + if commit = fossil_version.commit + FossilCommitRef.new commit + else + FossilTagRef.new "v#{fossil_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 #{fossil_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 = fossil_url + # Remove a "file://" from the beginning, otherwise the path might be invalid + # on Windows. + source = source.lchop("file://") + + Log.debug { "Local path: #{local_path}" } + fossil_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 "fossil clone #{Process.quote(source)} #{Process.quote(path)}.fossil" + end + end + + private def fetch_repository + fossil_retry(err: "Failed to update #{fossil_url}") do + run "fossil pull -R #{Process.quote(local_fossil_file)}" + end + end + + private def fossil_retry(err = "Failed to fetch repository") + retries = 0 + loop do + yield + break + rescue Error + retries += 1 + next if retries < 3 + raise Error.new(err) + 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.each_line(File.join(local_path, "config")) do |line| + return true if line =~ /mirror\s*=\s*true/ + end + false + end + + private def origin_url + @origin_url ||= capture("fossil remote-url -R #{Process.quote(local_fossil_file)}").strip + end + + # Returns whether origin URLs have differing hosts and/or paths. + protected def origin_changed? + return false if origin_url == fossil_url + return true if origin_url.nil? || fossil_url.nil? + + origin_parsed = parse_uri(origin_url) + fossil_parsed = parse_uri(fossil_url) + + (origin_parsed.host != fossil_parsed.host) || (origin_parsed.path != fossil_parsed.path) + end + + # Parses a URI string + 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 : FossilRef, path) + files = capture("fossil ls -R #{Process.quote(local_fossil_file)} -r #{Process.quote(ref.to_fossil_ref)} #{Process.quote(path)}") + !files.strip.empty? + end + + private def capture(command, path = local_path) + run(command, capture: true, path: path).not_nil! + end + + private def run(command, path = local_path, capture = false) + if Shards.local? && !Dir.exists?(path) + dependency_name = File.basename(path, ".fossil") + 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) + end + end + + private def run_in_current_folder(command, capture = false) + unless FossilResolver.has_fossil_command? + raise Error.new("Error missing fossil command line tool. Please install Fossil first!") + end + + Log.debug { command } + + STDERR.flush + 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? + output.to_s if capture + else + message = error.to_s + Log.debug { caller.join("\n => ") } + raise Error.new("Failed #{command} (#{message}). Maybe a commit, branch or file doesn't exist?") + end + end + + def report_version(version : Version) : String + fossil_version = parse_fossil_version(version) + if commit = fossil_version.commit + "#{fossil_version.value} at #{commit[0...7]}" + else + version.value + end + end + + register_resolver "fossil", FossilResolver + end +end From 41e55a9ee724c654291d5029390c5d64f7041bf5 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 01:21:13 -0600 Subject: [PATCH 03/23] Remove unneded lines --- src/resolvers/fossil.cr | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index b208aed2..e3ce7882 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -198,8 +198,6 @@ module Shards Log.debug { "Local path: #{local_path}" } Log.debug { "Install path: #{install_path}" } - install_fossil_file = Path[install_path].join("..", "#{name}.fossil").normalize.to_s - #run "fossil clone #{Process.quote(local_fossil_file)} #{install_fossil_file}" run "fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" end From dbc0fc20a7df46d41355a769bf1cadd05e3c48c1 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 02:54:28 -0600 Subject: [PATCH 04/23] Add documentation --- docs/shard.yml.adoc | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/shard.yml.adoc b/docs/shard.yml.adoc index 17382f00..67ca282b 100644 --- a/docs/shard.yml.adoc +++ b/docs/shard.yml.adoc @@ -335,6 +335,21 @@ required. When missing, Shards will install the _@_ bookmark or _tip_. + *Example:* _hg: https://hg.example.org/crystal-library_ +*fossil*:: + +A Fossil repository URL (string). ++ +The URL may be https://fossil-scm.org/home/help/clone[any protocol] +supported by Fossil, which includes SSH and HTTPS. ++ +The Fossil repository will be cloned, the list of versions (and associated +_shard.yml_) will be extracted from Fossil tags (e.g., _v1.2.3_). ++ +One of the other attributes (_version_, _tag_, _branch_, or _commit_) is +required. When missing, Shards will install _trunk_. ++ +*Example:* _fossil: https://fossil.example.org/crystal-library_ + *version*:: A version requirement (string). + @@ -357,14 +372,16 @@ the _~>_ operator has a special meaning, best shown by example: -- *branch*:: - Install the specified branch of a git dependency or the named branch - of a mercurial dependency (string). + Install the specified branch of a git dependency, or the named branch + of a mercurial or fossil dependency (string). *commit*:: - Install the specified commit of a git or mercurial dependency (string). + Install the specified commit of a git, mercurial, or fossil dependency + (string). *tag*:: - Install the specified tag of a git or mercurial dependency (string). + Install the specified tag of a git, mercurial, or fossil dependency + (string). *bookmark*:: Install the specified bookmark of a mercurial dependency (string). From aad749e882da2378a4e7ee9011880a3fa5c6d7a0 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 04:51:36 -0600 Subject: [PATCH 05/23] Add spec tests --- spec/support/factories.cr | 95 +++++++++++++++++ spec/support/requirement.cr | 4 + spec/unit/fossil_resolver_spec.cr | 168 ++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 spec/unit/fossil_resolver_spec.cr diff --git a/spec/support/factories.cr b/spec/support/factories.cr index 400cbc1a..090cd6c9 100644 --- a/spec/support/factories.cr +++ b/spec/support/factories.cr @@ -156,6 +156,86 @@ def checkout_hg_rev(project, rev) end end +def create_fossil_repository(project, *versions) + Dir.cd(tmp_path) do + run "fossil init #{Process.quote(project)}.fossil" + run "fossil open #{Process.quote(project)}.fossil --workdir #{Process.quote(fossil_path(project))}" + end + + Dir.mkdir(File.join(fossil_path(project), "src")) + File.write(File.join(fossil_path(project), "src", "#{project}.cr"), "module #{project.capitalize}\nend") + + Dir.cd(fossil_path(project)) do + run %|fossil add #{Process.quote("src/#{project}.cr")}| + end + + versions.each { |version| create_fossil_release project, version, tag: "v#{version}" } +end + +def create_fossil_release(project, version, shard : Bool | NamedTuple = true, tag : String? = nil) + create_fossil_version_commit(project, version, shard, tag) +end + +def create_fossil_version_commit(project, version, shard : Bool | NamedTuple = true, tag : String? = nil) + Dir.cd(fossil_path(project)) do + if shard + contents = shard.is_a?(NamedTuple) ? shard : nil + create_shard project, version, contents + end + + name = shard[:name]? if shard.is_a?(NamedTuple) + name ||= project + File.touch "src/#{name}.cr" + run "fossil addremove" + + create_fossil_commit project, "release: v#{version}", tag + end +end + +def create_fossil_commit(project, message = "new commit", tag : String? = nil) + Dir.cd(fossil_path(project)) do + File.write("src/#{project}.cr", "# #{message}", mode: "a") + run "fossil addremove" + + # Use --hash here to work around a file that's changed, but the size and + # mtime are the same. Depending on the resolution of mtime on the + # underlying filesystem, shard.yml may fall into this edge case during + # testing. + # + # https://fossil-users.fossil-scm.narkive.com/9ybRAo1U/error-file-is-different-on-disk-compared-to-the-repository-during-commti + if tag + run "fossil commit --hash --tag #{Process.quote(tag)} -m #{Process.quote(message)}" + else + run "fossil commit --hash -m #{Process.quote(message)}" + end + end +end + +def create_fork_fossil_repository(project, upstream) + Dir.cd(tmp_path) do + run "fossil clone #{Process.quote(fossil_url(upstream))} #{Process.quote(project)}" + end +end + +def create_fossil_tag(project, version) + Dir.cd(fossil_path(project)) do + run "fossil tag add #{Process.quote(version)} current" + end +end + +def checkout_new_fossil_branch(project, branch) + Dir.cd(fossil_path(project)) do + run "fossil branch new #{Process.quote(branch)} current" + run "fossil checkout branch" + end +end + +def checkout_fossil_rev(project, rev) + Dir.cd(fossil_path(project)) do + run "fossil checkout #{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 @@ -206,6 +286,20 @@ def hg_path(project) File.join(tmp_path, project.to_s) end +def fossil_commits(project, rev = "trunk") + Dir.cd(fossil_path(project)) do + run("fossil timeline #{Process.quote(rev)} -t ci -F %H").strip.split('\n')[..-2] + end +end + +def fossil_url(project) + "file://#{Path[fossil_path(project)].to_posix}" +end + +def fossil_path(project) + File.join(tmp_path, "#{project.to_s}") +end + def rel_path(project) "../../spec/.repositories/#{project}" end @@ -244,6 +338,7 @@ def run(command, *, env = nil) # FIXME: Concurrent streams are currently broken on Windows. Need to drop one for now. error = nil {% end %} + status = Process.run(command, shell: true, env: cmd_env, output: output, error: error || Process::Redirect::Close) if status.success? diff --git a/spec/support/requirement.cr b/spec/support/requirement.cr index e308dbde..b000cbc5 100644 --- a/spec/support/requirement.cr +++ b/spec/support/requirement.cr @@ -14,6 +14,10 @@ def hg_branch(name) Shards::HgBranchRef.new(name) end +def fossil_branch(name) + Shards::FossilBranchRef.new(name) +end + def version(version) Shards::Version.new(version) end diff --git a/spec/unit/fossil_resolver_spec.cr b/spec/unit/fossil_resolver_spec.cr new file mode 100644 index 00000000..9906c11e --- /dev/null +++ b/spec/unit/fossil_resolver_spec.cr @@ -0,0 +1,168 @@ +require "./spec_helper" + +private def resolver(name) + Shards::FossilResolver.new(name, fossil_url(name)) +end + +module Shards + # Allow overriding `source` for the specs + class FossilResolver + def source=(@source) + @origin_url = nil # This needs to be cleared so that #origin_url re-runs `fossil remote-url` + end + end + + describe FossilResolver do + before_each do + create_fossil_repository "empty" + create_fossil_commit "empty", "initial release" + + create_fossil_repository "unreleased" + create_fossil_version_commit "unreleased", "0.1.0" + checkout_new_fossil_branch "unreleased", "branch" + create_fossil_commit "unreleased", "testing" + checkout_fossil_rev "unreleased", "trunk" + + create_fossil_repository "unreleased-bm" + create_fossil_version_commit "unreleased-bm", "0.1.0" + create_fossil_commit "unreleased-bm", "testing" + checkout_fossil_rev "unreleased-bm", "trunk" + + create_fossil_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_fossil_tag "library", "99.9.9" + end + + it "normalizes sources" do + # don't normalise other domains + FossilResolver.normalize_key_source("fossil", "HTTPs://myfossilserver.com/Repo").should eq({"fossil", "HTTPs://myfossilserver.com/Repo"}) + + # don't change protocol from ssh + FossilResolver.normalize_key_source("fossil", "ssh://fossil@myfossilserver.com/Repo").should eq({"fossil", "ssh://fossil@myfossilserver.com/Repo"}) + end + + it "available releases" do + # Since we're working with the local filesystem, we need to use the .fossil files + resolver("empty.fossil").available_releases.should be_empty + resolver("library.fossil").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.fossil\" at commit #{fossil_commits(:empty)[0]}") do + resolver("empty.fossil").latest_version_for_ref(fossil_branch "tip") + end + expect_raises(Shards::Error, "No shard.yml was found for shard \"empty.fossil\" at commit #{fossil_commits(:empty)[0]}") do + resolver("empty.fossil").latest_version_for_ref(nil) + end + resolver("unreleased.fossil").latest_version_for_ref(fossil_branch "trunk").should eq(version "0.1.0+fossil.commit.#{fossil_commits(:unreleased)[0]}") + resolver("unreleased.fossil").latest_version_for_ref(fossil_branch "branch").should eq(version "0.1.0+fossil.commit.#{fossil_commits(:unreleased, "branch")[0]}") + resolver("unreleased.fossil").latest_version_for_ref(nil).should eq(version "0.1.0+fossil.commit.#{fossil_commits(:unreleased)[0]}") + resolver("unreleased-bm.fossil").latest_version_for_ref(fossil_branch "trunk").should eq(version "0.1.0+fossil.commit.#{fossil_commits("unreleased-bm")[0]}") + resolver("unreleased-bm.fossil").latest_version_for_ref(nil).should eq(version "0.1.0+fossil.commit.#{fossil_commits("unreleased-bm")[0]}") + resolver("library.fossil").latest_version_for_ref(fossil_branch "trunk").should eq(version "0.2.0+fossil.commit.#{fossil_commits(:library)[0]}") + resolver("library.fossil").latest_version_for_ref(nil).should eq(version "0.2.0+fossil.commit.#{fossil_commits(:library)[0]}") + expect_raises(Shards::Error, "Could not find branch foo for shard \"library.fossil\" in the repository #{fossil_url(:library)}") do + resolver("library.fossil").latest_version_for_ref(fossil_branch "foo") + end + end + + it "versions for" do + expect_raises(Shards::Error, "No shard.yml was found for shard \"empty.fossil\" at commit #{fossil_commits(:empty)[0]}") do + resolver("empty.fossil").versions_for(Any) + end + resolver("library.fossil").versions_for(Any).should eq(versions ["0.0.1", "0.1.0", "0.1.1", "0.1.2", "0.2.0"]) + resolver("library.fossil").versions_for(VersionReq.new "~> 0.1.0").should eq(versions ["0.1.0", "0.1.1", "0.1.2"]) + resolver("library.fossil").versions_for(fossil_branch "trunk").should eq(versions ["0.2.0+fossil.commit.#{fossil_commits(:library)[0]}"]) + resolver("unreleased.fossil").versions_for(fossil_branch "trunk").should eq(versions ["0.1.0+fossil.commit.#{fossil_commits(:unreleased)[0]}"]) + resolver("unreleased.fossil").versions_for(Any).should eq(versions ["0.1.0+fossil.commit.#{fossil_commits(:unreleased)[0]}"]) + resolver("unreleased-bm.fossil").versions_for(fossil_branch "trunk").should eq(versions ["0.1.0+fossil.commit.#{fossil_commits("unreleased-bm")[0]}"]) + resolver("unreleased-bm.fossil").versions_for(Any).should eq(versions ["0.1.0+fossil.commit.#{fossil_commits("unreleased-bm")[0]}"]) + end + + it "read spec for release" do + spec = resolver("library.fossil").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+fossil.commit.#{fossil_commits(:library)[0]}") + spec = resolver("library.fossil").spec(version) + spec.original_version.should eq(version "0.2.0") + spec.version.should eq(version) + end + + it "install" do + library = resolver("library.fossil") + + 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.fossil") + version = version "0.2.0+fossil.commit.#{fossil_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 = FossilResolver.new("library", fossil_url("library.fossil")) + library.install_sources(version("0.1.2"), install_path("library")) + + # Change the origin in the cache repo to https://foss.heptapod.net/foo/bar + Dir.cd(library.local_path) do + run "fossil remote-url -R #{library.name}.fossil https://foss.heptapod.net/foo/bar" + end + + # 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", + "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.fossil").report_version(version "1.2.3").should eq("1.2.3") + resolver("library.fossil").report_version(version "1.2.3+fossil.commit.654875c9dbfa8d72fba70d65fd548d51ffb85aff").should eq("1.2.3 at 654875c") + end + + it "#matches_ref" do + resolver = FossilResolver.new("", "") + resolver.matches_ref?(FossilCommitRef.new("1234567890abcdef"), Shards::Version.new("0.1.0.+fossil.commit.1234567")).should be_true + resolver.matches_ref?(FossilCommitRef.new("1234567890abcdef"), Shards::Version.new("0.1.0.+fossil.commit.1234567890abcdef")).should be_true + resolver.matches_ref?(FossilCommitRef.new("1234567"), Shards::Version.new("0.1.0.+fossil.commit.1234567890abcdef")).should be_true + end + end +end From c15fdd8876cd78342e2ad2187f654701e2648ed9 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 04:51:59 -0600 Subject: [PATCH 06/23] Fix FossilResolver so that it passes the specs. * Use "trunk", not "tip" * We don't need to actually open the Fossil repos in the cache once they're downloaded. --- src/resolvers/fossil.cr | 43 ++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index e3ce7882..f7b1520c 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -75,17 +75,17 @@ module Shards end end - struct FossilTipRef < FossilRef + struct FossilTrunkRef < FossilRef def to_fossil_ref - "tip" + "trunk" end def to_s(io) - io << "tip" + io << "trunk" end def to_yaml(yaml) - raise NotImplementedError.new("FossilTipRef is for internal use only") + raise NotImplementedError.new("FossilTrunkRef is for internal use only") end end @@ -135,7 +135,7 @@ module Shards private def spec_at_ref(ref : FossilRef, commit) : Spec update_local_cache - unless file_exists?(ref, SPEC_FILENAME) + unless capture("fossil ls -R #{Process.quote(local_fossil_file)} -r #{Process.quote(ref.to_fossil_ref)} #{Process.quote(SPEC_FILENAME)}").strip == SPEC_FILENAME raise Error.new "No #{SPEC_FILENAME} was found for shard #{name.inspect} at commit #{commit}" end @@ -159,7 +159,7 @@ module Shards def latest_version_for_ref(ref : FossilRef?) : Version update_local_cache - ref ||= FossilTipRef.new + ref ||= FossilTrunkRef.new begin commit = commit_sha1_at(ref) rescue Error @@ -174,7 +174,7 @@ module Shards case ref when FossilCommitRef ref =~ fossil_ref(version) - when FossilBranchRef, FossilTipRef + when FossilBranchRef, FossilTrunkRef # TODO: check if version is the branch version.has_metadata? else @@ -217,7 +217,7 @@ module Shards end if host = uri.host - File.join(Shards.cache_path, host, path) + File.join(Shards.cache_path, host) else File.join(Shards.cache_path, path) end @@ -225,7 +225,7 @@ module Shards end def local_fossil_file - @local_fossil_file ||= Path[local_path].join("..", "#{name}.fossil").normalize.to_s + @local_fossil_file ||= Path[local_path].join("#{name}.fossil").normalize.to_s end def fossil_url @@ -297,21 +297,17 @@ module Shards private def mirror_repository path = local_path - FileUtils.rm_r(path) if File.exists?(path) + fossil_file = Path[path].join("#{name}.fossil").to_s Dir.mkdir_p(path) + FileUtils.rm(fossil_file) if File.exists?(fossil_file) source = fossil_url # Remove a "file://" from the beginning, otherwise the path might be invalid # on Windows. source = source.lchop("file://") - Log.debug { "Local path: #{local_path}" } fossil_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 "fossil clone #{Process.quote(source)} #{Process.quote(path)}.fossil" + run_in_current_folder "fossil clone #{Process.quote(source)} #{Process.quote(fossil_file)}" end end @@ -326,16 +322,19 @@ module Shards loop do yield break - rescue Error + rescue inner_err : Error retries += 1 next if retries < 3 - raise Error.new(err) + Log.debug { inner_err } + raise Error.new("#{err}: #{inner_err}") end end private def delete_repository Log.debug { "rm -rf #{Process.quote(local_path)}'" } Shards::Helpers.rm_rf(local_path) + Log.debug { "rm -rf #{Process.quote(local_fossil_file)}'" } + Shards::Helpers.rm_rf(local_fossil_file) @origin_url = nil end @@ -344,13 +343,10 @@ module Shards end private def valid_repository? - File.each_line(File.join(local_path, "config")) do |line| - return true if line =~ /mirror\s*=\s*true/ - end - false + File.exists?(local_fossil_file) end - private def origin_url + protected def origin_url @origin_url ||= capture("fossil remote-url -R #{Process.quote(local_fossil_file)}").strip end @@ -426,7 +422,6 @@ module Shards output.to_s if capture else message = error.to_s - Log.debug { caller.join("\n => ") } raise Error.new("Failed #{command} (#{message}). Maybe a commit, branch or file doesn't exist?") end end From d33c81aad1af8d108ccfd0a661ae822ecbbae673 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 04:54:56 -0600 Subject: [PATCH 07/23] Install fossil --- .circleci/config.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f4f43c23..132f6bd8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -52,8 +52,8 @@ jobs: - image: crystallang/crystal:latest steps: - run: - name: Install mercurial - command: apt-get update && apt-get install mercurial -y + name: Install mercurial and fossil + command: apt-get update && apt-get install mercurial fossil -y - shards-make-test test-on-osx: @@ -63,8 +63,8 @@ jobs: - with-brew-cache: steps: - run: - name: Install Crystal and Mercurial - command: brew install crystal mercurial + name: Install Crystal, Mercurial, and Fossil + command: brew install crystal mercurial fossil - shards-make-test test-on-nightly: @@ -72,8 +72,8 @@ jobs: - image: crystallang/crystal:nightly steps: - run: - name: Install mercurial - command: apt-get update && apt-get install mercurial -y + name: Install mercurial and fossil + command: apt-get update && apt-get install mercurial fossil -y - shards-make-test workflows: From 5b816d14573ac580ccdd069e9db388357ffd660f Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 05:21:44 -0600 Subject: [PATCH 08/23] Install fossil (I hope) --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a610fdb3..4930ba88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,22 @@ jobs: with: crystal: ${{ matrix.crystal }} + - name: Install Fossil + if: ${{ matrix.os == 'ubuntu-latest' }} + run: | + sudo apt-get update + sudo apt-get install fossil + + - name: Install Fossil + if: ${{ matrix.os == 'macos-latest' }} + run: | + brew update + brew install fossil + + - name: Install Fossil + if: ${{ matrix.os == 'windows-latest' }} + run: choco install fossil + - name: Download source uses: actions/checkout@v2 From 5ae7bcd99e8f6116df491c201fd27be33cb02b82 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 05:49:12 -0600 Subject: [PATCH 09/23] Format using `crystal tool format` --- src/config.cr | 8 ++++---- src/resolvers/fossil.cr | 30 +++++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/config.cr b/src/config.cr index 8ee8db28..ad5fe113 100644 --- a/src/config.cr +++ b/src/config.cr @@ -9,10 +9,10 @@ module Shards DEFAULT_COMMAND = "install" DEFAULT_VERSION = "0" - 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]+)$/ + 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]+)$/ VERSION_AT_FOSSIL_COMMIT = /^(\d+[-.][-.a-zA-Z\d]+)\+fossil\.commit\.([0-9a-f]+)$/ def self.cache_path diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index f7b1520c..4370a5d0 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -207,21 +207,21 @@ module Shards def local_path @local_path ||= begin - uri = parse_uri(fossil_url) - - path = uri.path - path = Path[path] - # E.g. turns "c:\local\path.git" into "c\local\path.git". 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) - else - File.join(Shards.cache_path, path) - end - end + uri = parse_uri(fossil_url) + + path = uri.path + path = Path[path] + # E.g. turns "c:\local\path.git" into "c\local\path.git". 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) + else + File.join(Shards.cache_path, path) + end + end end def local_fossil_file From fbbf336e77c2a87fca82784c18fb258f95da64e7 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Thu, 28 Oct 2021 05:52:46 -0600 Subject: [PATCH 10/23] Be sure USER is set in the environment on Linux --- .circleci/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 132f6bd8..c4b778b7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,6 +54,9 @@ jobs: - run: name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y + - run: + name: Set environment variables + command: echo USER="$USER" >> "${BASH_ENV}" - shards-make-test test-on-osx: @@ -70,10 +73,15 @@ jobs: test-on-nightly: docker: - image: crystallang/crystal:nightly + environment: + USER: fossil-user steps: - run: name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y + - run: + name: Set environment variables + command: echo USER="$USER" >> "${BASH_ENV}" - shards-make-test workflows: From 9c12b0b5a86e3da854022a3fb681b5821534a69e Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Sat, 8 Jan 2022 13:55:28 -0700 Subject: [PATCH 11/23] Use -nested when opening the Fossil repository. This is necessary for projects that are themselves under Fossil. --- src/resolvers/fossil.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index 4370a5d0..e412c625 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -198,7 +198,7 @@ module Shards Log.debug { "Local path: #{local_path}" } Log.debug { "Install path: #{install_path}" } - run "fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" end def commit_sha1_at(ref : FossilRef) From 39c41e23a88fe03cfb23958fa321fcfc5d5ef56c Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Sun, 30 Jan 2022 15:36:27 -0700 Subject: [PATCH 12/23] spec: Use CIRCLE_PROJECT_USERNAME instead of USER for the username environment. --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c4b778b7..4b200ae9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -56,7 +56,7 @@ jobs: command: apt-get update && apt-get install mercurial fossil -y - run: name: Set environment variables - command: echo USER="$USER" >> "${BASH_ENV}" + command: echo USER="$CIRCLE_PROJECT_USERNAME" >> "${BASH_ENV}" - shards-make-test test-on-osx: @@ -76,7 +76,7 @@ jobs: environment: USER: fossil-user steps: - - run: + - run:yes name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y - run: From 5fa397de14431382fcba2484669b2528d70f3085 Mon Sep 17 00:00:00 2001 From: MistressRemilia <26493542+MistressRemilia@users.noreply.github.com> Date: Mon, 31 Jan 2022 02:46:37 -0700 Subject: [PATCH 13/23] Fix syntax error --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4b200ae9..bc72c068 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ jobs: environment: USER: fossil-user steps: - - run:yes + - run: name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y - run: From 0b07b11d9ebd4271d0da40053b3667f451fa7c1f Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Mon, 31 Jan 2022 22:21:18 -0700 Subject: [PATCH 14/23] Add workaround for older Fossil versions that don't have the --workdir argument for open. --- src/resolvers/fossil.cr | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index e412c625..8ba10968 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -91,6 +91,8 @@ module Shards class FossilResolver < Resolver @@has_fossil_command : Bool? + @@fossil_version_maj : Int8? + @@fossil_version_min : Int8? @@fossil_version : String? @origin_url : String? @@ -117,7 +119,24 @@ module Shards end protected def self.fossil_version - @@fossil_version ||= `fossil version`[/version\s+([^\s]*)/, 1] + unless @@fossil_version + @@fossil_version = `fossil version`[/version\s+([^\s]*)/, 1] + maj, min = @@fossil_version.not_nil!.split('.').map &.to_i8 + @@fossil_version_maj = maj + @@fossil_version_min = min + end + + @@fossil_version + end + + protected def self.fossil_version_maj + self.fossil_version unless @@fossil_version_maj + @@fossil_version_maj.not_nil! + end + + protected def self.fossil_version_min + self.fossil_version unless @@fossil_version_min + @@fossil_version_min.not_nil! end def read_spec(version : Version) : String? @@ -198,7 +217,16 @@ module Shards Log.debug { "Local path: #{local_path}" } Log.debug { "Install path: #{install_path}" } - run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + # The --workdir argument was introduced in version 2.12, so we have to + # fake it + if FossilResolver.fossil_version_maj <= 2 && + FossilResolver.fossil_version_min <= 12 + Dir.cd(install_path) do + run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)}" + end + else + run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + end end def commit_sha1_at(ref : FossilRef) From ff419d158e82bf9e68d270443dd3deb0aff6f3c2 Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Mon, 31 Jan 2022 22:21:57 -0700 Subject: [PATCH 15/23] Try something different with user environment variables *sigh* --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bc72c068..3c838172 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -56,7 +56,7 @@ jobs: command: apt-get update && apt-get install mercurial fossil -y - run: name: Set environment variables - command: echo USER="$CIRCLE_PROJECT_USERNAME" >> "${BASH_ENV}" + command: echo 'export USER=$CIRCLE_PROJECT_USERNAME' >> $BASH_ENV - shards-make-test test-on-osx: @@ -81,7 +81,7 @@ jobs: command: apt-get update && apt-get install mercurial fossil -y - run: name: Set environment variables - command: echo USER="$USER" >> "${BASH_ENV}" + command: echo 'export USER=$CIRCLE_PROJECT_USERNAME' >> $BASH_ENV - shards-make-test workflows: From a3bca1239c10bdd71bb6946424fd6cc73c70a8ec Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Mon, 31 Jan 2022 22:39:07 -0700 Subject: [PATCH 16/23] Use less than, not less than or equal. --- src/resolvers/fossil.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index 8ba10968..9a2d6016 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -220,7 +220,7 @@ module Shards # The --workdir argument was introduced in version 2.12, so we have to # fake it if FossilResolver.fossil_version_maj <= 2 && - FossilResolver.fossil_version_min <= 12 + FossilResolver.fossil_version_min < 12 Dir.cd(install_path) do run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)}" end From 9833fd0ee4dd0c0ad6c06b709dbe369ced742cf9 Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Mon, 31 Jan 2022 22:39:52 -0700 Subject: [PATCH 17/23] Use the environment: key to export environment variables for jobs --- .circleci/config.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3c838172..fa22344f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -50,13 +50,12 @@ jobs: test: docker: - image: crystallang/crystal:latest + environment: + USER: shardsuser steps: - run: name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y - - run: - name: Set environment variables - command: echo 'export USER=$CIRCLE_PROJECT_USERNAME' >> $BASH_ENV - shards-make-test test-on-osx: @@ -74,14 +73,11 @@ jobs: docker: - image: crystallang/crystal:nightly environment: - USER: fossil-user + USER: shardsuser steps: - run: name: Install mercurial and fossil command: apt-get update && apt-get install mercurial fossil -y - - run: - name: Set environment variables - command: echo 'export USER=$CIRCLE_PROJECT_USERNAME' >> $BASH_ENV - shards-make-test workflows: From b911ff6d90eec1e9e87cab888dcb7e4a8df8328f Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Tue, 1 Feb 2022 01:49:52 -0700 Subject: [PATCH 18/23] Improve support for older versions of Fossil. Versions before 2.12 did not have a --workdir argument for the timeline command, and were more strict about argument ordering. This can be faked using Dir#cd and arguments in the correct order. Versions before 2.14 did not support the --format/-F argument for the timeline command. As a workaround, we can use timeline + whatis to get the full artifact hash. --- src/resolvers/fossil.cr | 55 +++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index 9a2d6016..fdada65e 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -93,6 +93,7 @@ module Shards @@has_fossil_command : Bool? @@fossil_version_maj : Int8? @@fossil_version_min : Int8? + @@fossil_version_rev : Int8? @@fossil_version : String? @origin_url : String? @@ -121,9 +122,10 @@ module Shards protected def self.fossil_version unless @@fossil_version @@fossil_version = `fossil version`[/version\s+([^\s]*)/, 1] - maj, min = @@fossil_version.not_nil!.split('.').map &.to_i8 - @@fossil_version_maj = maj - @@fossil_version_min = min + pieces = @@fossil_version.not_nil!.split('.') + @@fossil_version_maj = pieces[0].to_i8 + @@fossil_version_min = pieces[1].to_i8 + @@fossil_version_rev = (pieces[2]?.try &.to_i8 || 0i8) end @@fossil_version @@ -139,6 +141,11 @@ module Shards @@fossil_version_min.not_nil! end + protected def self.fossil_version_rev + self.fossil_version unless @@fossil_version_rev + @@fossil_version_rev.not_nil! + end + def read_spec(version : Version) : String? update_local_cache ref = fossil_ref(version) @@ -185,8 +192,11 @@ module Shards raise Error.new "Could not find #{ref.full_info} for shard #{name.inspect} in the repository #{source}" end - spec = spec_at_ref(ref, commit) - Version.new "#{spec.version.value}+fossil.commit.#{commit}" + if spec = spec_at_ref(ref, commit) + Version.new "#{spec.version.value}+fossil.commit.#{commit}" + else + raise Error.new "No #{SPEC_FILENAME} was found for shard #{name.inspect} at commit #{commit}" + end end def matches_ref?(ref : FossilRef, version : Version) @@ -221,16 +231,41 @@ module Shards # fake it if FossilResolver.fossil_version_maj <= 2 && FossilResolver.fossil_version_min < 12 - Dir.cd(install_path) do - run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)}" - end + Log.debug { "Opening Fossil repo #{local_fossil_file} in directory #{install_path}" } + run("fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --nested", install_path) else - run "fossil open -nested #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --workdir #{install_path}" + run "fossil open #{local_fossil_file} #{Process.quote(ref.to_fossil_ref)} --nested --workdir #{install_path}" end end def commit_sha1_at(ref : FossilRef) - capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -n 1 -F %H -R #{Process.quote(local_fossil_file)}").strip.lines[0] + # Fossil versions before 2.14 do not support the --format/-F for the + # timeline command. + if FossilResolver.fossil_version_maj <= 2 && + FossilResolver.fossil_version_min < 14 + # Capture the short artifact name from the timeline using a regex. + # -W 0 = unlimited line width + # -n 1 = limit results to one entry + # -t ci = Display only checkins on the timeline + shortShas = capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -W 0 -n 1 -R #{Process.quote(local_fossil_file)}") + + # We only want the lines with short artifact names + retLines = shortShas.strip.lines.flat_map do |line| + /^.+ \[(.+)\].*/.match(line).try &.[1] + end + + # Remove empty results + retLines.reject! &.nil? + return "" if retLines.empty? + + # Call the whatis command so we can properly expand the short artifact + # name to the full artifact hash. + whatis = capture("fossil whatis #{retLines[0]} -R #{Process.quote(local_fossil_file)}") + /artifact:\s+(.+)/.match(whatis).try &.[1] || "" + else + # Fossil v2.14 and newer support -F %H, so use that. + capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -F %H -n 1 -R #{Process.quote(local_fossil_file)}") + end end def local_path From a5958a8e8b83628accdc695c1610085ce26adaa6 Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Tue, 1 Feb 2022 01:52:15 -0700 Subject: [PATCH 19/23] Fix tests --- spec/support/factories.cr | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/spec/support/factories.cr b/spec/support/factories.cr index 090cd6c9..03116891 100644 --- a/spec/support/factories.cr +++ b/spec/support/factories.cr @@ -159,7 +159,14 @@ end def create_fossil_repository(project, *versions) Dir.cd(tmp_path) do run "fossil init #{Process.quote(project)}.fossil" - run "fossil open #{Process.quote(project)}.fossil --workdir #{Process.quote(fossil_path(project))}" + + # Use a workaround so we don't use --workdir in case the specs are run on a + # machine with an old Fossil version. See the #install_sources method in + # src/resolvers/fossil.cr + Dir.mkdir(fossil_path(project)) unless Dir.exists?(fossil_path(project)) + Dir.cd(fossil_path(project)) do + run "fossil open #{Process.quote(File.join(tmp_path, project))}.fossil" + end end Dir.mkdir(File.join(fossil_path(project), "src")) @@ -287,8 +294,17 @@ def hg_path(project) end def fossil_commits(project, rev = "trunk") + # This is using the workaround code in case the machine running the specs is + # using an old Fossil version. See the #commit_sha1_at method in + # src/resolvers/fossil.cr for info. Dir.cd(fossil_path(project)) do - run("fossil timeline #{Process.quote(rev)} -t ci -F %H").strip.split('\n')[..-2] + retStr = run("fossil timeline #{Process.quote(rev)} -t ci -W 0").strip.lines + retLines = retStr.flat_map do |line| + /^.+ \[(.+)\].*/.match(line).try &.[1] + end + + retLines.reject! &.nil? + [/artifact:\s+(.+)/.match(run("fossil whatis #{retLines[0]}")).not_nil!.[1]] end end From f6f036ad93f3a3ea24415597049926c044c82e0f Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Tue, 1 Feb 2022 02:12:20 -0700 Subject: [PATCH 20/23] Use #split instead of #lines --- spec/support/factories.cr | 2 +- src/resolvers/fossil.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/support/factories.cr b/spec/support/factories.cr index cce67f08..7f3e3491 100644 --- a/spec/support/factories.cr +++ b/spec/support/factories.cr @@ -306,7 +306,7 @@ def fossil_commits(project, rev = "trunk") # using an old Fossil version. See the #commit_sha1_at method in # src/resolvers/fossil.cr for info. Dir.cd(fossil_path(project)) do - retStr = run("fossil timeline #{Process.quote(rev)} -t ci -W 0").strip.lines + retStr = run("fossil timeline #{Process.quote(rev)} -t ci -W 0").strip.split('\n') retLines = retStr.flat_map do |line| /^.+ \[(.+)\].*/.match(line).try &.[1] end diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index fdada65e..8fdf4279 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -250,7 +250,7 @@ module Shards shortShas = capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -W 0 -n 1 -R #{Process.quote(local_fossil_file)}") # We only want the lines with short artifact names - retLines = shortShas.strip.lines.flat_map do |line| + retLines = shortShas.strip.split('\n').flat_map do |line| /^.+ \[(.+)\].*/.match(line).try &.[1] end From eb62bcfb9103cccef5acc67014534ddde72be1e5 Mon Sep 17 00:00:00 2001 From: Remilia Scarlet <26493542+MistressRemilia@users.noreply.github.com> Date: Wed, 9 Feb 2022 16:30:37 -0700 Subject: [PATCH 21/23] Update .github/workflows/ci.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Müller --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4930ba88..eaec8412 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: crystal: ${{ matrix.crystal }} - name: Install Fossil - if: ${{ matrix.os == 'ubuntu-latest' }} + if: ${{ runner.os == 'Linux' }} run: | sudo apt-get update sudo apt-get install fossil From 615868af9aa4079b151c63086b2ef06b032c0b72 Mon Sep 17 00:00:00 2001 From: Remilia Scarlet <26493542+MistressRemilia@users.noreply.github.com> Date: Wed, 9 Feb 2022 16:30:53 -0700 Subject: [PATCH 22/23] Update docs/shard.yml.adoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Müller --- docs/shard.yml.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/shard.yml.adoc b/docs/shard.yml.adoc index 67ca282b..436af88b 100644 --- a/docs/shard.yml.adoc +++ b/docs/shard.yml.adoc @@ -337,7 +337,7 @@ required. When missing, Shards will install the _@_ bookmark or _tip_. *fossil*:: -A Fossil repository URL (string). +A https://www.fossil-scm.org[Fossil] repository URL (string). + The URL may be https://fossil-scm.org/home/help/clone[any protocol] supported by Fossil, which includes SSH and HTTPS. From 2a90c8a1ca3d1e6e4953f81045f870714737f528 Mon Sep 17 00:00:00 2001 From: MistressRemilia <4798372-RemiliaScarlet@users.noreply.gitlab.com> Date: Wed, 9 Feb 2022 19:37:52 -0700 Subject: [PATCH 23/23] Ignore the extra '--- entry limit (1) reached ---' string that gets printed. --- src/resolvers/fossil.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/fossil.cr b/src/resolvers/fossil.cr index 8fdf4279..eda50fb5 100644 --- a/src/resolvers/fossil.cr +++ b/src/resolvers/fossil.cr @@ -264,7 +264,7 @@ module Shards /artifact:\s+(.+)/.match(whatis).try &.[1] || "" else # Fossil v2.14 and newer support -F %H, so use that. - capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -F %H -n 1 -R #{Process.quote(local_fossil_file)}") + capture("fossil timeline #{Process.quote(ref.to_fossil_ref)} -t ci -F %H -n 1 -R #{Process.quote(local_fossil_file)}").split('\n')[0] end end