Feature/remote repos (#5796)

* support working locally on remote repositories

* refactoring

* specs for remote git repos

* do not use %x because it's unsafe

* fixed spec (create new temp dir for repo)

* allow both escaped and unescaped strings

* code climate

[ci skip]
pull/5811/head
Markus Kahl 7 years ago committed by Oliver Günther
parent b8355caf51
commit 52d87ff81b
  1. 7
      app/models/repository.rb
  2. 5
      config/locales/en.yml
  3. 7
      docs/configuration/configuration.md
  4. 1
      lib/open_project/configuration.rb
  5. 131
      lib/open_project/scm/adapters/git.rb
  6. 8
      lib/open_project/scm/adapters/local_client.rb
  7. 3
      lib/open_project/scm/adapters/subversion.rb
  8. 791
      spec/lib/open_project/scm/adapters/git_adapter_spec.rb

@ -81,8 +81,11 @@ class Repository < ActiveRecord::Base
end end
def scm def scm
@scm ||= scm_adapter.new(url, root_url, @scm ||= scm_adapter.new(
login, password, path_encoding) url, root_url,
login, password, path_encoding,
project.identifier
)
# override the adapter's root url with the full url # override the adapter's root url with the full url
# if none other was set. # if none other was set.

@ -1815,7 +1815,10 @@ en:
git: git:
instructions: instructions:
managed_url: "This is the URL of the managed (local) Git repository." managed_url: "This is the URL of the managed (local) Git repository."
path: "Specify the path to your local Git repository ( e.g., %{example_path} )." path: >-
Specify the path to your local Git repository ( e.g., %{example_path} ).
You can also use remote repositories which are cloned to a local copy by
using a value starting with http(s):// or file://.
path_encoding: "Override Git path encoding (Default: UTF-8)" path_encoding: "Override Git path encoding (Default: UTF-8)"
local_title: "Link existing local Git repository" local_title: "Link existing local Git repository"
local_url: "Local URL" local_url: "Local URL"

@ -50,6 +50,7 @@ storage config above like this:
* `database_cipher_key` (default: nil) * `database_cipher_key` (default: nil)
* `scm_git_command` (default: 'git') * `scm_git_command` (default: 'git')
* `scm_subversion_command` (default: 'svn') * `scm_subversion_command` (default: 'svn')
* [`scm_local_checkout_path`](#local-checkout-path) (default: 'repositories')
* `force_help_link` (default: nil) * `force_help_link` (default: nil)
* `session_store`: `active_record_store`, `cache_store`, or `cookie_store` (default: cache_store) * `session_store`: `active_record_store`, `cache_store`, or `cookie_store` (default: cache_store)
* `drop_old_sessions_on_logout` (default: true) * `drop_old_sessions_on_logout` (default: true)
@ -260,6 +261,12 @@ The option to use a string is mostly relevant for when you want to override the
OPENPROJECT_DISABLED__MODULES='backlogs meetings' OPENPROJECT_DISABLED__MODULES='backlogs meetings'
``` ```
## local checkout path
*default: "repositories"*
Remote git repositories will be checked out here.
### APIv3 basic auth control ### APIv3 basic auth control
**default: true** **default: true**

@ -46,6 +46,7 @@ module OpenProject
'force_help_link' => nil, 'force_help_link' => nil,
'scm_git_command' => nil, 'scm_git_command' => nil,
'scm_subversion_command' => nil, 'scm_subversion_command' => nil,
'scm_local_checkout_path' => 'repositories', # relative to OpenProject directory
'disable_browser_cache' => true, 'disable_browser_cache' => true,
# default cache_store is :file_store in production and :memory_store in development # default cache_store is :file_store in production and :memory_store in development
'rails_cache_store' => nil, 'rails_cache_store' => nil,

@ -37,10 +37,18 @@ module OpenProject
SCM_GIT_REPORT_LAST_COMMIT = true SCM_GIT_REPORT_LAST_COMMIT = true
def initialize(url, root_url = nil, _login = nil, _password = nil, path_encoding = nil) def initialize(url, root_url = nil, _login = nil, _password = nil, path_encoding = nil, identifier = nil)
super(url, root_url) super(url, root_url)
@flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT @flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT
@path_encoding = path_encoding.presence || 'UTF-8' @path_encoding = path_encoding.presence || 'UTF-8'
@identifier = identifier
if checkout? && identifier.blank?
raise ArgumentError, %{
No identifier given. The git adapter requires a project identifier when working
on a repository remotely.
}.squish
end
end end
def checkout_command def checkout_command
@ -73,7 +81,15 @@ module OpenProject
## ##
# Create a bare repository for the current path # Create a bare repository for the current path
def initialize_bare_git def initialize_bare_git
capture_git(%w[init --bare --shared]) unless checkout_uri.start_with?("/")
raise ArgumentError, "Cannot initialize bare repository remotely at #{checkout_uri}"
end
capture_git(%w[init --bare --shared], no_chdir: true)
end
def git_dir
@git_dir ||= URI.parse(checkout_uri).path
end end
## ##
@ -82,15 +98,93 @@ module OpenProject
# #
# @raise [ScmUnavailable] raised when repository is unavailable. # @raise [ScmUnavailable] raised when repository is unavailable.
def check_availability! def check_availability!
refresh_repository!
# If it is not empty, it should have at least one branch # If it is not empty, it should have at least one branch
# Any exit code != 0 will raise here # Any exit code != 0 will raise here
raise Exceptions::ScmEmpty unless branches.count > 0 raise Exceptions::ScmEmpty unless branches.count > 0
rescue Exceptions::CommandFailed => e rescue Exceptions::CommandFailed => e
logger.error("Availability check failed due to failed Git command: #{e.message}") logger.error("Availability check failed due to failed Git command: #{e.message}")
raise Exceptions::ScmUnavailable raise Exceptions::ScmUnavailable
end end
##
# Checks if the repository is up-to-date. It is not it's updated.
# Checks out the repository if necessary.
def refresh_repository!
if checkout?
checkout_repository! if !File.directory?(checkout_path)
update_repository!
end
end
def checkout_repository!
Rails.logger.info "Checking out #{checkout_uri} to #{checkout_path}"
FileUtils.mkdir_p checkout_path.parent
capture_out(
["clone", checkout_uri, checkout_path.to_s],
error_message: "Failed to clone #{checkout_uri} to #{checkout_path}"
)
end
def update_repository!
Rails.logger.debug "Fetching latest commits for #{checkout_path}"
Dir.chdir checkout_path do
fetch_all
remote_branches = self.remote_branches
local_branches = self.local_branches
remote_branches.each do |remote_branch|
local_branch = remote_branch.sub /^origin\//, ""
if !local_branches.include?(local_branch)
track_branch! local_branch, remote_branch
else
update_branch! local_branch, remote_branch
end
end
end
end
def fetch_all
capture_out %w[fetch --all], error_message: "Failed to fetch latest commits"
end
def remote_branches
capture_out(%w[branch -r], error_message: "Failed to list remote branches")
.split("\n")
.map(&:strip)
.reject { |b| b.include?("->") } # origin/HEAD -> origin/master
end
def local_branches
capture_out(%w[branch], error_message: "Failed to list local branches")
.split("\n")
.map(&:strip)
.map { |b| b.sub(/^\* /, "") } # remove marker for current branch
end
def track_branch!(local_branch, remote_branch)
Rails.logger.info("Setting up new branch: #{local_branch} -> #{remote_branch}")
capture_out(
["branch", "--track", local_branch, remote_branch],
error_message: "Failed to track new branch #{remote_branch}"
)
end
def update_branch!(local_branch, remote_branch)
Rails.logger.debug("Updating branch: #{local_branch}")
capture_out(
["update-ref", local_branch, remote_branch],
error_message: "Failed update ref to #{remote_branch}"
)
end
def info def info
Info.new(root_url: url, lastrev: lastrev('', nil)) Info.new(root_url: url, lastrev: lastrev('', nil))
end end
@ -100,6 +194,20 @@ module OpenProject
capture_git(cmd_args).chomp == 'true' capture_git(cmd_args).chomp == 'true'
end end
def checkout?
checkout_uri =~ /\A\w+:\/\/.+\Z/ # check if it starts file:// or http(s):// etc
end
def checkout_path
Pathname(OpenProject::Configuration.scm_local_checkout_path)
.join(@identifier)
.expand_path
end
def checkout_uri
root_url.presence || url
end
def branches def branches
return @branches if @branches return @branches if @branches
@branches = [] @branches = []
@ -356,7 +464,8 @@ module OpenProject
# Builds the full git arguments from the parameters # Builds the full git arguments from the parameters
# and return the executed stdout as a string # and return the executed stdout as a string
def capture_git(args, opt = {}) def capture_git(args, opt = {})
cmd = build_git_cmd(args) opt = opt.merge(chdir: checkout_path) if checkout? && !opt[:no_chdir]
cmd = build_git_cmd(args, opt.slice(:no_chdir))
capture_out(cmd, opt) capture_out(cmd, opt)
end end
@ -365,15 +474,19 @@ module OpenProject
# and calls the given block with in, out, err, thread # and calls the given block with in, out, err, thread
# from +Open3#popen3+. # from +Open3#popen3+.
def popen3(args, opt = {}, &block) def popen3(args, opt = {}, &block)
opt = opt.merge(chdir: checkout_path) if checkout?
cmd = build_git_cmd(args) cmd = build_git_cmd(args)
super(cmd, opt) do |_stdin, stdout, _stderr, wait_thr| super(cmd, opt) do |_stdin, stdout, stderr, wait_thr|
block.call(stdout) block.call(stdout)
process = wait_thr.value process = wait_thr.value
if process.exitstatus != 0 if process.exitstatus != 0
raise Exceptions::CommandFailed.new( raise Exceptions::CommandFailed.new(
'git', 'git',
"git exited with non-zero status: #{process.exitstatus}" %{
`git #{args.join(' ')}` exited with non-zero status:
#{process.exitstatus} (#{stderr.read})
}.squish
) )
end end
end end
@ -390,12 +503,14 @@ module OpenProject
end end
end end
def build_git_cmd(args) def build_git_cmd(args, opts = {})
if client_version_above?([1, 7, 2]) if client_version_above?([1, 7, 2])
args.unshift('-c', 'core.quotepath=false') args.unshift('-c', 'core.quotepath=false')
end end
args.unshift('--git-dir', (root_url.presence || url)) # make sure to use bare repository path to initialize a managed repository
args.unshift('--git-dir', git_dir) unless checkout? && !opts[:no_chdir]
args
end end
end end
end end

@ -156,12 +156,16 @@ module OpenProject
# If the operation throws an exception or the operation yields a non-zero exit code # If the operation throws an exception or the operation yields a non-zero exit code
# we rethrow a +CommandFailed+ with a meaningful error message # we rethrow a +CommandFailed+ with a meaningful error message
def capture_out(args, opts = {}) def capture_out(args, opts = {})
output, err, code = Open3.capture3(client_command, *args, binmode: opts[:binmode]) output, err, code = Open3.capture3(client_command, *args, opts.slice(:binmode, :chdir))
if code != 0 if code != 0
error_msg = "SCM command failed: Non-zero exit code (#{code}) for `#{client_command}`" error_msg = "SCM command failed: Non-zero exit code (#{code}) for `#{client_command}`"
logger.error(error_msg) logger.error(error_msg)
logger.debug("Error output is #{err}") logger.debug("Error output is #{err}")
raise Exceptions::CommandFailed.new(client_command, error_msg, err) raise Exceptions::CommandFailed.new(
client_command,
opts[:error_message] || error_msg,
err
)
end end
output output

@ -73,11 +73,12 @@ module OpenProject
root_url.sub('file://', '') root_url.sub('file://', '')
end end
def initialize(url, root_url = nil, login = nil, password = nil, _path_encoding = nil) def initialize(url, root_url = nil, login = nil, password = nil, _path_encoding = nil, identifier = nil)
super(url, root_url) super(url, root_url)
@login = login @login = login
@password = password @password = password
@identifier = identifier
end end
def checkout_command def checkout_command

@ -30,476 +30,515 @@
require 'spec_helper' require 'spec_helper'
describe OpenProject::Scm::Adapters::Git do describe OpenProject::Scm::Adapters::Git do
let(:url) { Rails.root.join('/tmp/does/not/exist.git').to_s } shared_examples "git adapter specs" do
let(:config) { {} } let(:protocol) { "" }
let(:encoding) { nil } let(:url) { protocol + Rails.root.join('/tmp/does/not/exist.git').to_s }
let(:adapter) { let(:config) { {} }
OpenProject::Scm::Adapters::Git.new( let(:encoding) { nil }
url, let(:adapter) {
nil, OpenProject::Scm::Adapters::Git.new(
nil, url,
nil, nil,
encoding nil,
) nil,
} encoding,
"test-identifier"
before do )
allow(adapter.class).to receive(:config).and_return(config) }
end
repos_dir = Dir.mktmpdir
describe 'client information' do before do
it 'sets the Git client command' do allow(adapter.class).to receive(:config).and_return(config)
expect(adapter.client_command).to eq('git')
end
context 'with client command from config' do allow(OpenProject::Configuration)
let(:config) { { client_command: '/usr/local/bin/git' } } .to receive(:scm_local_checkout_path)
it 'overrides the Git client command from config' do .and_return(repos_dir)
expect(adapter.client_command).to eq('/usr/local/bin/git')
end
end end
shared_examples 'correct client version' do |git_string, expected_version| describe 'client information' do
it 'should set the correct client version' do it 'sets the Git client command' do
expect(adapter) expect(adapter.client_command).to eq('git')
.to receive(:scm_version_from_command_line)
.and_return(git_string)
expect(adapter.client_version).to eq(expected_version)
expect(adapter.client_available).to be true
expect(adapter.client_version_string).to eq(expected_version.join('.'))
end end
end
it_behaves_like 'correct client version', "git version 1.7.3.4\n", [1, 7, 3, 4]
it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1]
it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2]
end
describe 'invalid repository' do context 'with client command from config' do
describe '.check_availability!' do let(:config) { { client_command: '/usr/local/bin/git' } }
it 'should not be available' do it 'overrides the Git client command from config' do
expect(Dir.exists?(url)).to be false expect(adapter.client_command).to eq('/usr/local/bin/git')
expect(adapter).not_to be_available end
expect { adapter.check_availability! }
.to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
end end
it 'should raise a meaningful error if shell output fails' do shared_examples 'correct client version' do |git_string, expected_version|
expect(adapter).to receive(:branches) it 'should set the correct client version' do
.and_raise OpenProject::Scm::Exceptions::CommandFailed.new('git', '') expect(adapter)
.to receive(:scm_version_from_command_line)
.and_return(git_string)
expect { adapter.check_availability! } expect(adapter.client_version).to eq(expected_version)
.to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable) expect(adapter.client_available).to be true
expect(adapter.client_version_string).to eq(expected_version.join('.'))
end
end end
end
end
describe 'empty repository' do it_behaves_like 'correct client version', "git version 1.7.3.4\n", [1, 7, 3, 4]
include_context 'with tmpdir' it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1]
let(:url) { tmpdir } it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2]
before do
adapter.initialize_bare_git
end end
describe '.check_availability!' do describe 'invalid repository' do
shared_examples 'check_availibility raises empty' do describe '.check_availability!' do
it do it 'should not be available' do
expect(Dir.exists?(url)).to be false
expect(adapter).not_to be_available
expect { adapter.check_availability! } expect { adapter.check_availability! }
.to raise_error(OpenProject::Scm::Exceptions::ScmEmpty) .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
end end
end
it_behaves_like 'check_availibility raises empty' it 'should raise a meaningful error if shell output fails' do
expect { adapter.check_availability! }
describe 'Git version compatibility' do .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable)
before do
allow(::Open3).to receive(:capture2e).and_return(output, nil)
end
context 'older Git version' do
let(:output) { "fatal: bad default revision 'HEAD'\n" }
it_behaves_like 'check_availibility raises empty'
end
context 'new Git version' do
let(:output) { "fatal: your current branch 'master' does not have any commits yet\n" }
it_behaves_like 'check_availibility raises empty'
end end
end end
end end
end
describe 'local repository' do describe 'empty repository' do
with_git_repository do |repo_dir| include_context 'with tmpdir'
let(:url) { repo_dir } let(:url) { tmpdir }
it 'reads the git version' do before do
expect(adapter.client_version.length).to be >= 3 adapter.initialize_bare_git
end end
it 'is a valid repository' do describe '.check_availability!' do
expect(Dir.exists?(repo_dir)).to be true shared_examples 'check_availibility raises empty' do
it do
out, process = Open3.capture2e('git', '--git-dir', repo_dir, 'branch') expect { adapter.check_availability! }
expect(process.exitstatus).to eq(0) .to raise_error(OpenProject::Scm::Exceptions::ScmEmpty)
expect(out).to include('master') end
end end
it 'should be available' do it_behaves_like 'check_availibility raises empty'
expect(adapter).to be_available
expect { adapter.check_availability! }.to_not raise_error
end
it 'should read tags' do describe 'Git version compatibility' do
expect(adapter.tags).to match_array(%w[tag00.lightweight tag01.annotated]) before do
end allow(::Open3).to receive(:capture2e).and_return(output, nil)
end
describe '.branches' do context 'older Git version' do
it 'should show the default branch' do let(:output) { "fatal: bad default revision 'HEAD'\n" }
expect(adapter.default_branch).to eq('master') it_behaves_like 'check_availibility raises empty'
end end
it 'should read branches' do context 'new Git version' do
branches = %w[latin-1-path-encoding master test-latin-1 test_branch] let(:output) { "fatal: your current branch 'master' does not have any commits yet\n" }
expect(adapter.branches).to match_array(branches) it_behaves_like 'check_availibility raises empty'
end
end end
end end
end
describe '.info' do describe 'local repository' do
it 'builds the info object' do with_git_repository do |repo_dir|
info = adapter.info let(:url) { "#{protocol}#{repo_dir}" }
expect(info.root_url).to eq(repo_dir)
expect(info.lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
end
end
describe '.lastrev' do before do
let(:felix_hex) { "Felix Sch\xC3\xA4fer" } # make sure the repository is available before even bothering
# with the rest of the tests
expect(adapter).to be_available
expect { adapter.check_availability! }.to_not raise_error
end
it 'references the last revision for empty path' do it 'reads the git version' do
lastrev = adapter.lastrev('', nil) expect(adapter.client_version.length).to be >= 3
expect(lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
end end
it 'references the last revision of the given path' do it 'is a valid repository' do
lastrev = adapter.lastrev('README', nil) expect(Dir.exists?(repo_dir)).to be true
expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
expect(lastrev.author).to eq('Adam Soltys <asoltys@gmail.com>')
expect(lastrev.time).to eq('2009-06-24 07:27:38 +0200')
# Even though that commit has a message, lastrev doesn't parse that deliberately out, process = Open3.capture2e('git', '--git-dir', repo_dir, 'branch')
expect(lastrev.message).to eq('') expect(process.exitstatus).to eq(0)
expect(lastrev.branch).to be_nil expect(out).to include('master')
expect(lastrev.paths).to be_nil
end end
it 'references the last revision of the given path and identifier' do it 'should be using checkout' do
lastrev = adapter.lastrev('README', '4f26664364207fa8b1af9f8722647ab2d4ac5d43') if protocol.blank?
expect(lastrev.scmid).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8') expect(adapter).not_to be_checkout
expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8') else
expect(lastrev.author).to eq('Adam Soltys <asoltys@gmail.com>') expect(adapter).to be_checkout
expect(lastrev.time).to eq('2009-06-24 05:27:38') end
end end
it 'works with spaces in filename' do it 'should be available' do
lastrev = adapter.lastrev('filemane with spaces.txt', expect(adapter).to be_available
'ed5bb786bbda2dee66a2d50faf51429dbc043a7b') expect { adapter.check_availability! }.to_not raise_error
expect(lastrev.identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.time).to eq('2010-09-18 19:59:46')
end end
it 'encodes strings correctly' do it 'should read tags' do
lastrev = adapter.lastrev('filemane with spaces.txt', expect(adapter.tags).to match_array(%w[tag00.lightweight tag01.annotated])
'ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.author).to eq('Felix Schäfer <felix@fachschaften.org>')
expect(lastrev.author).to eq("#{felix_hex} <felix@fachschaften.org>")
end end
end
describe '.revisions' do describe '.branches' do
it 'should retrieve all revisions' do it 'should show the default branch' do
rev = adapter.revisions('', nil, nil, all: true) expect(adapter.default_branch).to eq('master')
expect(rev.length).to eq(22) end
end
it 'should retrieve the latest revision' do it 'should read branches' do
rev = adapter.revisions('', nil, nil, all: true) branches = %w[latin-1-path-encoding master test-latin-1 test_branch]
expect(rev.latest.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') expect(adapter.branches).to match_array(branches)
expect(rev.latest.format_identifier).to eq('71e5c1d3') end
end end
it 'should retrieve a certain revisions' do describe '.info' do
rev = adapter.revisions('', '899a15d^', '899a15d') it 'builds the info object' do
expect(rev.length).to eq(1) info = adapter.info
expect(rev[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') expect(info.root_url).to eq("#{protocol}#{repo_dir}")
expect(rev[0].author).to eq('jsmith <jsmith@foo.bar>') expect(info.lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
end
end end
it 'should retrieve revisions in reverse' do describe '.lastrev' do
rev = adapter.revisions('', nil, nil, all: true, reverse: true) let(:felix_hex) { "Felix Sch\xC3\xA4fer" }
expect(rev.length).to eq(22)
expect(rev[0].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
expect(rev[20].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
end
it 'should retrieve revisions in a specific time frame' do it 'references the last revision for empty path' do
since = Time.gm(2010, 9, 30, 0, 0, 0) lastrev = adapter.lastrev('', nil)
rev = adapter.revisions('', nil, nil, all: true, since: since) expect(lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(rev.length).to eq(7) end
expect(rev[0].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(rev[1].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
expect(rev[5].identifier).to eq('9a6f3b947d16f11b537363a60904d1b1d3bfcd2f')
expect(rev[6].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834')
end
it 'should retrieve revisions in a specific time frame in reverse' do it 'references the last revision of the given path' do
since = Time.gm(2010, 9, 30, 0, 0, 0) lastrev = adapter.lastrev('README', nil)
rev = adapter.revisions('', nil, nil, all: true, since: since, reverse: true) expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
expect(rev.length).to eq(7) expect(lastrev.author).to eq('Adam Soltys <asoltys@gmail.com>')
expect(rev[0].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834') expect(lastrev.time).to eq('2009-06-24 07:27:38 +0200')
expect(rev[5].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
expect(rev[6].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
end
it 'should retrieve revisions by filename' do # Even though that commit has a message, lastrev doesn't parse that deliberately
rev = adapter.revisions('filemane with spaces.txt', nil, nil, all: true) expect(lastrev.message).to eq('')
expect(rev.length).to eq(1) expect(lastrev.branch).to be_nil
expect(rev[0].identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') expect(lastrev.paths).to be_nil
end end
it 'should retrieve revisions with arbitrary whitespace' do it 'references the last revision of the given path and identifier' do
file = ' filename with a leading space.txt ' lastrev = adapter.lastrev('README', '4f26664364207fa8b1af9f8722647ab2d4ac5d43')
rev = adapter.revisions(file, nil, nil, all: true) expect(lastrev.scmid).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
expect(rev.length).to eq(1) expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8')
expect(rev[0].paths[0][:path]).to eq(file) expect(lastrev.author).to eq('Adam Soltys <asoltys@gmail.com>')
end expect(lastrev.time).to eq('2009-06-24 05:27:38')
end
it 'works with spaces in filename' do
lastrev = adapter.lastrev('filemane with spaces.txt',
'ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(lastrev.time).to eq('2010-09-18 19:59:46')
end
it 'should show all paths of a revision' do it 'encodes strings correctly' do
rev = adapter.revisions('', '899a15d^', '899a15d')[0] lastrev = adapter.lastrev('filemane with spaces.txt',
expect(rev.paths.length).to eq(3) 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
expect(rev.paths[0]).to eq(action: 'M', path: 'README') expect(lastrev.author).to eq('Felix Schäfer <felix@fachschaften.org>')
expect(rev.paths[1]).to eq(action: 'A', path: 'images/edit.png') expect(lastrev.author).to eq("#{felix_hex} <felix@fachschaften.org>")
expect(rev.paths[2]).to eq(action: 'A', path: 'sources/welcome_controller.rb') end
end end
end
describe '.entries' do describe '.revisions' do
shared_examples 'retrieve entries' do it 'should retrieve all revisions' do
it 'should retrieve entries from an identifier' do rev = adapter.revisions('', nil, nil, all: true)
entries = adapter.entries('', '83ca5fd') expect(rev.length).to eq(22)
expect(entries.length).to eq(9) end
expect(entries[0].name).to eq('images') it 'should retrieve the latest revision' do
expect(entries[0].kind).to eq('dir') rev = adapter.revisions('', nil, nil, all: true)
expect(entries[0].size).to be_nil expect(rev.latest.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(entries[0]).to be_dir expect(rev.latest.format_identifier).to eq('71e5c1d3')
expect(entries[0]).not_to be_file end
expect(entries[3]).to be_file it 'should retrieve a certain revisions' do
expect(entries[3].size).to eq(56) rev = adapter.revisions('', '899a15d^', '899a15d')
expect(entries[3].name).to eq(' filename with a leading space.txt ') expect(rev.length).to eq(1)
end expect(rev[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
expect(rev[0].author).to eq('jsmith <jsmith@foo.bar>')
it 'should have a related revision' do end
entries = adapter.entries('', '83ca5fd')
rev = entries[0].lastrev it 'should retrieve revisions in reverse' do
expect(rev.identifier).to eq('deff712f05a90d96edbd70facc47d944be5897e3') rev = adapter.revisions('', nil, nil, all: true, reverse: true)
expect(rev.author).to eq('Adam Soltys <asoltys@gmail.com>') expect(rev.length).to eq(22)
expect(rev[0].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
rev = entries[3].lastrev expect(rev[20].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
expect(rev.identifier).to eq('83ca5fd546063a3c7dc2e568ba3355661a9e2b2c') end
expect(rev.author).to eq('Felix Schäfer <felix@fachschaften.org>')
end it 'should retrieve revisions in a specific time frame' do
since = Time.gm(2010, 9, 30, 0, 0, 0)
it 'can be retrieved by tag' do rev = adapter.revisions('', nil, nil, all: true, since: since)
entries = adapter.entries(nil, 'tag01.annotated') expect(rev.length).to eq(7)
expect(entries.length).to eq(3) expect(rev[0].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(rev[1].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
sources = entries[1] expect(rev[5].identifier).to eq('9a6f3b947d16f11b537363a60904d1b1d3bfcd2f')
expect(sources.name).to eq('sources') expect(rev[6].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834')
expect(sources.path).to eq('sources') end
expect(sources).to be_dir
it 'should retrieve revisions in a specific time frame in reverse' do
readme = entries[2] since = Time.gm(2010, 9, 30, 0, 0, 0)
expect(readme.name).to eq('README') rev = adapter.revisions('', nil, nil, all: true, since: since, reverse: true)
expect(readme.path).to eq('README') expect(rev.length).to eq(7)
expect(readme).to be_file expect(rev[0].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834')
expect(readme.size).to eq(27) expect(rev[5].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127')
expect(readme.lastrev.identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') expect(rev[6].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(readme.lastrev.time).to eq(Time.gm(2007, 12, 14, 9, 24, 1)) end
end
it 'should retrieve revisions by filename' do
it 'can be retrieved by branch' do rev = adapter.revisions('filemane with spaces.txt', nil, nil, all: true)
entries = adapter.entries(nil, 'test_branch') expect(rev.length).to eq(1)
expect(entries.length).to eq(4) expect(rev[0].identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b')
sources = entries[1] end
expect(sources.name).to eq('sources')
expect(sources.path).to eq('sources') it 'should retrieve revisions with arbitrary whitespace' do
expect(sources).to be_dir file = ' filename with a leading space.txt '
rev = adapter.revisions(file, nil, nil, all: true)
readme = entries[2] expect(rev.length).to eq(1)
expect(readme.name).to eq('README') expect(rev[0].paths[0][:path]).to eq(file)
expect(readme.path).to eq('README') end
expect(readme).to be_file
expect(readme.size).to eq(159) it 'should show all paths of a revision' do
rev = adapter.revisions('', '899a15d^', '899a15d')[0]
expect(readme.lastrev.identifier).to eq('713f4944648826f558cf548222f813dabe7cbb04') expect(rev.paths.length).to eq(3)
expect(readme.lastrev.time).to eq(Time.gm(2009, 6, 19, 4, 37, 23)) expect(rev.paths[0]).to eq(action: 'M', path: 'README')
expect(rev.paths[1]).to eq(action: 'A', path: 'images/edit.png')
expect(rev.paths[2]).to eq(action: 'A', path: 'sources/welcome_controller.rb')
end end
end end
describe 'encoding' do describe '.entries' do
let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') } shared_examples 'retrieve entries' do
it 'should retrieve entries from an identifier' do
entries = adapter.entries('', '83ca5fd')
expect(entries.length).to eq(9)
context 'with default encoding' do expect(entries[0].name).to eq('images')
it_behaves_like 'retrieve entries' expect(entries[0].kind).to eq('dir')
expect(entries[0].size).to be_nil
expect(entries[0]).to be_dir
expect(entries[0]).not_to be_file
it 'can retrieve directories containing entries encoded in latin-1' do expect(entries[3]).to be_file
entries = adapter.entries('latin-1-dir', '64f1f3e8') expect(entries[3].size).to eq(56)
f1 = entries[1] expect(entries[3].name).to eq(' filename with a leading space.txt ')
end
expect(f1.name).to eq("test-\xDC-2.txt") it 'should have a related revision' do
expect(f1.path).to eq("latin-1-dir/test-\xDC-2.txt") entries = adapter.entries('', '83ca5fd')
expect(f1).to be_file rev = entries[0].lastrev
expect(rev.identifier).to eq('deff712f05a90d96edbd70facc47d944be5897e3')
expect(rev.author).to eq('Adam Soltys <asoltys@gmail.com>')
rev = entries[3].lastrev
expect(rev.identifier).to eq('83ca5fd546063a3c7dc2e568ba3355661a9e2b2c')
expect(rev.author).to eq('Felix Schäfer <felix@fachschaften.org>')
end end
it 'cannot retrieve files with latin-1 encoding in their path' do it 'can be retrieved by tag' do
entries = adapter.entries('latin-1-dir', '64f1f3e8') entries = adapter.entries(nil, 'tag01.annotated')
latin1_path = entries[1].path expect(entries.length).to eq(3)
sources = entries[1]
expect(sources.name).to eq('sources')
expect(sources.path).to eq('sources')
expect(sources).to be_dir
readme = entries[2]
expect(readme.name).to eq('README')
expect(readme.path).to eq('README')
expect(readme).to be_file
expect(readme.size).to eq(27)
expect(readme.lastrev.identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
expect(readme.lastrev.time).to eq(Time.gm(2007, 12, 14, 9, 24, 1))
end
expect { adapter.entries(latin1_path, '1ca7f5ed') } it 'can be retrieved by branch' do
.to raise_error(OpenProject::Scm::Exceptions::CommandFailed) entries = adapter.entries(nil, 'test_branch')
expect(entries.length).to eq(4)
sources = entries[1]
expect(sources.name).to eq('sources')
expect(sources.path).to eq('sources')
expect(sources).to be_dir
readme = entries[2]
expect(readme.name).to eq('README')
expect(readme.path).to eq('README')
expect(readme).to be_file
expect(readme.size).to eq(159)
expect(readme.lastrev.identifier).to eq('713f4944648826f558cf548222f813dabe7cbb04')
expect(readme.lastrev.time).to eq(Time.gm(2009, 6, 19, 4, 37, 23))
end end
end end
context 'with latin-1 encoding' do describe 'encoding' do
let (:encoding) { 'ISO-8859-1' } let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') }
it_behaves_like 'retrieve entries' context 'with default encoding' do
it_behaves_like 'retrieve entries'
it 'can be retrieved with latin-1 encoding' do it 'can retrieve directories containing entries encoded in latin-1' do
entries = adapter.entries('latin-1-dir', '64f1f3e8') entries = adapter.entries('latin-1-dir', '64f1f3e8')
expect(entries.length).to eq(3) f1 = entries[1]
f1 = entries[1]
expect(f1.name).to eq("test-\xDC-2.txt")
expect(f1.path).to eq("latin-1-dir/test-\xDC-2.txt")
expect(f1).to be_file
end
it 'cannot retrieve files with latin-1 encoding in their path' do
entries = adapter.entries('latin-1-dir', '64f1f3e8')
latin1_path = entries[1].path
expect(f1.name).to eq("test-#{char1_hex}-2.txt") expect { adapter.entries(latin1_path, '1ca7f5ed') }
expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-2.txt") .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
expect(f1).to be_file end
end end
it 'can be retrieved with latin-1 directories' do context 'with latin-1 encoding' do
entries = adapter.entries("latin-1-dir/test-#{char1_hex}-subdir", let (:encoding) { 'ISO-8859-1' }
'1ca7f5ed')
expect(entries.length).to eq(3) it_behaves_like 'retrieve entries'
f1 = entries[1]
it 'can be retrieved with latin-1 encoding' do
entries = adapter.entries('latin-1-dir', '64f1f3e8')
expect(entries.length).to eq(3)
f1 = entries[1]
expect(f1.name).to eq("test-#{char1_hex}-2.txt")
expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-2.txt")
expect(f1).to be_file
end
expect(f1).to be_file it 'can be retrieved with latin-1 directories' do
expect(f1.name).to eq("test-#{char1_hex}-2.txt") entries = adapter.entries("latin-1-dir/test-#{char1_hex}-subdir",
expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-subdir/test-#{char1_hex}-2.txt") '1ca7f5ed')
expect(entries.length).to eq(3)
f1 = entries[1]
expect(f1).to be_file
expect(f1.name).to eq("test-#{char1_hex}-2.txt")
expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-subdir/test-#{char1_hex}-2.txt")
end
end end
end end
end end
end
describe '.annotate' do describe '.annotate' do
it 'should annotate a regular file' do it 'should annotate a regular file' do
annotate = adapter.annotate('sources/watchers_controller.rb') annotate = adapter.annotate('sources/watchers_controller.rb')
expect(annotate).to be_kind_of(OpenProject::Scm::Adapters::Annotate) expect(annotate).to be_kind_of(OpenProject::Scm::Adapters::Annotate)
expect(annotate.lines.length).to eq(41) expect(annotate.lines.length).to eq(41)
expect(annotate.lines[4].strip).to eq('# This program is free software; '\ expect(annotate.lines[4].strip).to eq('# This program is free software; '\
'you can redistribute it and/or') 'you can redistribute it and/or')
expect(annotate.revisions[4].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') expect(annotate.revisions[4].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518')
expect(annotate.revisions[4].author).to eq('jsmith') expect(annotate.revisions[4].author).to eq('jsmith')
end end
it 'should annotate moved file' do it 'should annotate moved file' do
annotate = adapter.annotate('renamed_test.txt') annotate = adapter.annotate('renamed_test.txt')
expect(annotate.lines.length).to eq(2) expect(annotate.lines.length).to eq(2)
expect(annotate.content).to eq("This is a test\nLet's pretend I'm adding a new feature!") expect(annotate.content).to eq("This is a test\nLet's pretend I'm adding a new feature!")
expect(annotate.lines).to match_array(['This is a test', expect(annotate.lines).to match_array(['This is a test',
"Let's pretend I'm adding a new feature!"]) "Let's pretend I'm adding a new feature!"])
expect(annotate.revisions.length).to eq(2) expect(annotate.revisions.length).to eq(2)
expect(annotate.revisions[0].identifier).to eq('fba357b886984ee71185ad2065e65fc0417d9b92') expect(annotate.revisions[0].identifier).to eq('fba357b886984ee71185ad2065e65fc0417d9b92')
expect(annotate.revisions[1].identifier).to eq('7e61ac704deecde634b51e59daa8110435dcb3da') expect(annotate.revisions[1].identifier).to eq('7e61ac704deecde634b51e59daa8110435dcb3da')
end end
it 'should annotate with identifier' do it 'should annotate with identifier' do
annotate = adapter.annotate('README', 'HEAD~10') annotate = adapter.annotate('README', 'HEAD~10')
expect(annotate.lines.length).to eq(1) expect(annotate.lines.length).to eq(1)
expect(annotate.empty?).to be false expect(annotate.empty?).to be false
expect(annotate.content).to eq("Mercurial test repository\r") expect(annotate.content).to eq("Mercurial test repository\r")
expect(annotate.revisions.length).to eq(1) expect(annotate.revisions.length).to eq(1)
expect(annotate.revisions[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') expect(annotate.revisions[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9')
expect(annotate.revisions[0].author).to eq('jsmith') expect(annotate.revisions[0].author).to eq('jsmith')
end end
it 'should raise for an invalid path' do it 'should raise for an invalid path' do
expect { adapter.annotate('does_not_exist.txt') } expect { adapter.annotate('does_not_exist.txt') }
.to raise_error(OpenProject::Scm::Exceptions::CommandFailed) .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
expect { adapter.annotate('/path/outside/repository') } expect { adapter.annotate('/path/outside/repository') }
.to raise_error(OpenProject::Scm::Exceptions::CommandFailed) .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
end end
it 'should return nil for binary path' do it 'should return nil for binary path' do
expect(adapter.annotate('images/edit.png')).to be_nil expect(adapter.annotate('images/edit.png')).to be_nil
end end
# We should rethink the output of annotated files for these formats. # We should rethink the output of annotated files for these formats.
it 'also returns nil for UTF-16 encoded file' do it 'also returns nil for UTF-16 encoded file' do
expect(adapter.annotate('utf16.txt')).to be_nil expect(adapter.annotate('utf16.txt')).to be_nil
end
end end
end
describe '.cat' do describe '.cat' do
it 'outputs the given file' do it 'outputs the given file' do
out = adapter.cat('README') out = adapter.cat('README')
expect(out).to include('Git test repository') expect(out).to include('Git test repository')
end end
it 'raises an exception for an invalid file' do it 'raises an exception for an invalid file' do
expect { adapter.cat('doesnotexiss') } expect { adapter.cat('doesnotexiss') }
.to raise_error(OpenProject::Scm::Exceptions::CommandFailed) .to raise_error(OpenProject::Scm::Exceptions::CommandFailed)
end
end end
end
describe '.diff' do describe '.diff' do
it 'provides a full diff of the last commit by default' do it 'provides a full diff of the last commit by default' do
diff = adapter.diff('', 'HEAD') diff = adapter.diff('', 'HEAD')
expect(diff[0]).to eq('commit 71e5c1d3dca6304805b143b9d0e6695fb3895ea4') expect(diff[0]).to eq('commit 71e5c1d3dca6304805b143b9d0e6695fb3895ea4')
expect(diff[1]).to eq("Author: Oliver G\xFCnther <mail@oliverguenther.de>")
end
it 'provides a negative diff' do bare = "Author: Oliver G\xFCnther <mail@oliverguenther.de>"
diff = adapter.diff('', 'HEAD~2', 'HEAD~1') cloned = "Author: Oliver Günther <mail@oliverguenther.de>"
expect(diff.join("\n")).to include('-And this is a file')
end
it 'provides the complete for the given range' do # The strings returned by capture_out have escaped UTF-8 characters depending on
diff = adapter.diff('', '61b685f', '2f9c009') # wether we are working on a cloned or bare repository. I don't know why.
expect(diff[1]).to eq('index 6cbd30c..b94e68e 100644') # It doesn't make a difference further down the road, though. So just check both.
expect(diff[10]).to eq('index 4eca635..9a541fe 100644') expect(diff[1] == bare || diff[1] == cloned).to eq true
end end
it 'provides a negative diff' do
diff = adapter.diff('', 'HEAD~2', 'HEAD~1')
expect(diff.join("\n")).to include('-And this is a file')
end
it 'provides the complete for the given range' do
diff = adapter.diff('', '61b685f', '2f9c009')
expect(diff[1]).to eq('index 6cbd30c..b94e68e 100644')
expect(diff[10]).to eq('index 4eca635..9a541fe 100644')
end
it 'provides the selected diff for the given range' do it 'provides the selected diff for the given range' do
diff = adapter.diff('README', '61b685f', '2f9c009') diff = adapter.diff('README', '61b685f', '2f9c009')
expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n")) expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n"))
diff --git a/README b/README diff --git a/README b/README
index 6cbd30c..b94e68e 100644 index 6cbd30c..b94e68e 100644
--- a/README --- a/README
+++ b/README +++ b/README
@@ -1 +1,4 @@ @@ -1 +1,4 @@
Mercurial test repository Mercurial test repository
+ +
+Mercurial is a distributed version control system. Mercurial is dedicated to speed and efficiency with a sane user interface. +Mercurial is a distributed version control system. Mercurial is dedicated to speed and efficiency with a sane user interface.
+It is written in Python. +It is written in Python.
DIFF DIFF
end
end end
end end
end end
end end
context "with a local repository" do
it_behaves_like 'git adapter specs'
end
context "with a remote repository" do
it_behaves_like 'git adapter specs' do
let(:protocol) { "file://" } # make it remote by using a protocol
end
end
end end

Loading…
Cancel
Save