diff --git a/lib/releaseme/svn.rb b/lib/releaseme/svn.rb index 991961f..ae4c5bc 100644 --- a/lib/releaseme/svn.rb +++ b/lib/releaseme/svn.rb @@ -1,126 +1,200 @@ -#-- -# Copyright (C) 2007-2015 Harald Sitter -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of -# the License or (at your option) version 3 or any later version -# accepted by the membership of KDE e.V. (or its successor approved -# by the membership of KDE e.V.), which shall act as a proxy -# defined in Section 14 of version 3 of the license. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#++ +# SPDX-FileCopyrightText: 2007-2020 Harald Sitter +# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL require 'fileutils' require 'open3' require_relative 'logable' require_relative 'vcs' module ReleaseMe # Wrapper around Subversion. class Svn < Vcs prepend Logable + class Error < StandardError + RA_ILLEGAL_URL = 170000 + ILLEGAL_TARGET = 200009 # list/cat with invalid targets (target path doesnt exist) + + attr_reader :codes + + def initialize(codes, result) + @codes = codes.uniq + super(<<-EOF) +Unexpected SVN Errors + +Please file a bug against releaseme for investigation at bugs.kde.org. +Chances are this is a server-side problem though. +You could try again in a couple minutes. + + cmd: #{result.cmd} + status: #{result.status} + error(s): #{codes.join(', ')} + + -- stdout -- +#{result.out.rstrip} + -- stderr -- +#{result.err.rstrip} + ------------ + EOF + end + end + + class Result + attr_reader :cmd + attr_reader :status + attr_reader :out + attr_reader :err + + def initialize(cmd) + @cmd = cmd.freeze + end + + def capture3(args) + raise unless args.size == 3 + @out, @err, @status = *args + end + + def success? + @status.success? + end + + def empty? + out.empty? && err.empty? + end + + def maybe_raise + return if success? + + codes = [] + + err.lines.each do |line| + code = line.match(/^svn: E(?\d+):.*/)&.[](:code) + next if !code || code.empty? + codes << code.to_i + end + + raise Error.new(codes, self) unless codes.empty? + end + end + # Checkout a path from the remote repository. # @param target is the target directory for the checkout # @param path is an additional path to append to the repo URL # @return [Boolean] whether the checkout was successful def get(target, path = nil, clean: false) url?(target) url = repository.dup # Deep copy since we will patch around url.concat("/#{path}") if path && !path.empty? _output, status = run(['co', url, target]) clean!(target) if clean status.success? + rescue Error => e + raise e unless e.codes == [Error::RA_ILLEGAL_URL] + false end # Removes .svn recursively from target. def clean!(target) Dir.glob("#{target}/**/**/.svn").each { |d| FileUtils.rm_rf(d) } end # List content of a directory in the remote repository. # If path is nil the ls will be run on the @repository url. # @return [String] output of command if successful. $? is set to return value. def list(path = nil) url = repository.dup # Deep copy since we will patch around url.concat("/#{path}") if path && !path.empty? output, _status = run(['ls', url]) output + rescue Error => e + raise e unless e.codes == [Error::ILLEGAL_TARGET] + '' end # Concatenate to output. # @param file_path filepath to append to the repository URL # @return [String] content of cat'd path def cat(file_path) output, _status = run(['cat', "#{repository}/#{file_path}"]) output + rescue Error => e + raise e unless e.codes == [Error::ILLEGAL_TARGET] + '' end # Export single file from remote repository. # @param path filepath to append to the repository URL # @param targetFilePath target file path to write to # @return [Boolean] whether or not the export was successful def export(target, path) url?(target) _output, status = run(['export', "#{repository}/#{path}", target]) status.success? + rescue Error => e + raise e unless e.codes == [Error::RA_ILLEGAL_URL] + false end # Checks whether a file/dir exists on the remote repository # @param filePath filepath to append to the repository URL # @return [Boolean] whether or not the path exists def exist?(path) _output, status = run(['info', "#{repository}/#{path}"]) status.success? + rescue Error => e + raise e unless e.codes == [Error::ILLEGAL_TARGET] + false end def to_s "(svn - #{repository})" end private # @return [String, status] output of command def run(args) cmd = %w[svn] + args log_debug cmd.join(' ') - output, status = Open3.capture2e(*cmd) + result = Result.new(cmd) + result.capture3(Open3.capture3({ 'LANG' => 'C.UTF-8' }, *cmd)) + debug_result(result) + # for testing. we want to verify codes all the time so we need to track # the last most status somewhere. this must not be used for production # code that gets threaded. - @status = status.dup - debug_output(output) + @status = result.status.dup + + result.maybe_raise # Do not return error output as it will screw with output processing. - [status.success? ? output : '', status] + [result.success? ? result.out : '', result.status] end - def debug_output(output) - return if logger.level != Logger::DEBUG || output.empty? - log_debug '-- output --' - output.lines.each { |l| log_debug l.rstrip } - log_debug '------------' + def debug_result(result) + return if logger.level != Logger::DEBUG || result.empty? + # log this in one go to be thread synchronized + log_debug <<-OUTPUT + +-- stdout #{result.status} -- +#{result.out.lines.collect(&:rstrip).join("\n")} +-- stderr #{status} -- +#{result.err.lines.collect(&:rstrip).join("\n")} +'------------' + OUTPUT end def url?(path) if path.match('((\w|\W)+)://.*') log_warn 'possbily inverted argument order detected!' return true end false end # Calling this on the same instance of svn is not thread safe. Since we # only use it in tests it's marked private! # @return [ProcessStatus] exit status of last command that ran. attr_reader :status end end diff --git a/test/svn_test.rb b/test/svn_test.rb index 03a14bb..fde5e70 100644 --- a/test/svn_test.rb +++ b/test/svn_test.rb @@ -1,173 +1,179 @@ -#-- -# Copyright (C) 2014-2015 Harald Sitter -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of -# the License or (at your option) version 3 or any later version -# accepted by the membership of KDE e.V. (or its successor approved -# by the membership of KDE e.V.), which shall act as a proxy -# defined in Section 14 of version 3 of the license. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#++ +# SPDX-FileCopyrightText: 2014-2020 Harald Sitter +# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL require 'fileutils' require_relative 'lib/testme' require_relative '../lib/releaseme/svn' class TestSvn < Testme def setup @svn_checkout_dir = "#{Dir.pwd}/tmp_check_" + (0...16).map{ ('a'..'z').to_a[rand(26)] }.join @svn_repo_dir = "#{Dir.pwd}/tmp_repo_" + (0...16).map{ ('a'..'z').to_a[rand(26)] }.join system("svnadmin create #{@svn_repo_dir}", [:out] => File::NULL) || raise assert_path_exist(@svn_repo_dir) end def teardown FileUtils.rm_rf(@svn_repo_dir) FileUtils.rm_rf(@svn_checkout_dir) end def populate_repo `svn co file:///#{@svn_repo_dir} #{@svn_checkout_dir}` File.write("#{@svn_checkout_dir}/foo", 'yolo') Dir.mkdir("#{@svn_checkout_dir}/dir") File.write("#{@svn_checkout_dir}/dir/file", 'oloy') Dir.chdir(@svn_checkout_dir) do system('svn', 'add', *Dir.glob('*'), [:out] => File::NULL) || raise system('svn', 'ci', '-m', 'I am a troll', [:out] => File::NULL) || raise end end def new_valid_repo s = ReleaseMe::Svn.new s.repository = "file:///#{@svn_repo_dir}" s end def test_cat populate_repo s = new_valid_repo # Valid file. ret = s.cat('/foo') assert(s.send(:status).success?) assert_equal('yolo', ret) # Invalid file. ret = s.cat('/bar') refute(s.send(:status).success?) assert_equal('', ret) end def test_exists populate_repo s = new_valid_repo # Valid file. ret = s.exist?('/foo') assert_equal(true, ret) # Invalid file. ret = s.exist?('/bar') assert_equal(false, ret) end def test_list populate_repo s = new_valid_repo # Valid path. ret = s.list assert_equal("dir/\nfoo\n", ret) # Invalid path. ret = s.list('/invalid') assert_equal('', ret) # Valid path other than / ret = s.list('/dir') assert_equal('file', ret.strip) end def test_export tmpDir = Dir.pwd + "/tmp_svn_export_" + (0...16).map{ ('a'..'z').to_a[rand(26)] }.join Dir.mkdir(tmpDir) populate_repo s = new_valid_repo # Valid target and path ret = s.export("#{tmpDir}/file", '/dir/file') assert_equal(true, ret) assert_path_exist("#{tmpDir}/file") # Target dir does not exist - ret = s.export("#{tmpDir}123/file", '/dir/file') - assert_equal(false, ret) + assert_raises ReleaseMe::Svn::Error do + s.export("#{tmpDir}123/file", '/dir/file') + end refute_path_exist("#{tmpDir}123/file") # Invalid path ret = s.export("#{tmpDir}/file", '/dir/otherfile') assert_equal(false, ret) refute_path_exist("#{tmpDir}/otherfile") ensure FileUtils.rm_rf(tmpDir) end def test_get_repo_valid s = ReleaseMe::Svn.new s.repository = "file:///#{@svn_repo_dir}" ret = s.get(@svn_checkout_dir) assert_equal(true, ret) assert_path_exist(@svn_checkout_dir) FileUtils.rm_rf(@svn_checkout_dir) end def test_get_repo_invalid s = ReleaseMe::Svn.new s.repository = 'file://foofooofoo' - s.get(@svn_checkout_dir) + assert_raises ReleaseMe::Svn::Error do + s.get(@svn_checkout_dir) + end refute_path_exist(@svn_checkout_dir) FileUtils.rm_rf(@svn_checkout_dir) end def test_clean populate_repo s = new_valid_repo s.get(@svn_checkout_dir) s.clean!(@svn_checkout_dir) refute_path_exist("#{@svn_checkout_dir}/.svn") refute_path_exist("#{@svn_checkout_dir}/dir/.svn") end def test_from_hash s = ReleaseMe::Svn.from_hash(repository: 'kitten') refute_nil(s) assert_equal('kitten', s.repository) end def test_to_s s = ReleaseMe::Svn.from_hash(repository: 'kitten') assert_equal('(svn - kitten)', s.to_s) end def test_get_with_clean populate_repo s = new_valid_repo s.get(@svn_checkout_dir, clean: true) refute_path_exist("#{@svn_checkout_dir}/.svn") refute_path_exist("#{@svn_checkout_dir}/dir/.svn") end + + def test_connection_closed + # simulate the remote closing the connection. this can happen when + # the connection limit is exhausted on the remote + + err = <<-STDERR +svn: E170013: Unable to connect to a repository at URL 'svn://anonsvn.kde.org/home/kde/trunk/l10n-kf5/wa/messages/kde-workspace' +svn: E210002: Network connection closed unexpectedly + STDERR + + status = mock('status') + status.stubs(:success?).returns(false) + + result = ReleaseMe::Svn::Result.new('svn foo') + result.capture3(['', err, status]) + ex = assert_raises ReleaseMe::Svn::Error do + result.maybe_raise + end + assert_equal([170013, 210002], ex.codes.sort) + end end