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
def scm
@scm ||= scm_adapter.new(url, root_url,
login, password, path_encoding)
@scm ||= scm_adapter.new(
url, root_url,
login, password, path_encoding,
project.identifier
)
# override the adapter's root url with the full url
# if none other was set.

@ -1815,7 +1815,10 @@ en:
git:
instructions:
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)"
local_title: "Link existing local Git repository"
local_url: "Local URL"

@ -50,6 +50,7 @@ storage config above like this:
* `database_cipher_key` (default: nil)
* `scm_git_command` (default: 'git')
* `scm_subversion_command` (default: 'svn')
* [`scm_local_checkout_path`](#local-checkout-path) (default: 'repositories')
* `force_help_link` (default: nil)
* `session_store`: `active_record_store`, `cache_store`, or `cookie_store` (default: cache_store)
* `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'
```
## local checkout path
*default: "repositories"*
Remote git repositories will be checked out here.
### APIv3 basic auth control
**default: true**

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

@ -37,10 +37,18 @@ module OpenProject
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)
@flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT
@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
def checkout_command
@ -73,7 +81,15 @@ module OpenProject
##
# Create a bare repository for the current path
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
##
@ -82,15 +98,93 @@ module OpenProject
#
# @raise [ScmUnavailable] raised when repository is unavailable.
def check_availability!
refresh_repository!
# If it is not empty, it should have at least one branch
# Any exit code != 0 will raise here
raise Exceptions::ScmEmpty unless branches.count > 0
rescue Exceptions::CommandFailed => e
logger.error("Availability check failed due to failed Git command: #{e.message}")
raise Exceptions::ScmUnavailable
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
Info.new(root_url: url, lastrev: lastrev('', nil))
end
@ -100,6 +194,20 @@ module OpenProject
capture_git(cmd_args).chomp == 'true'
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
return @branches if @branches
@branches = []
@ -356,7 +464,8 @@ module OpenProject
# Builds the full git arguments from the parameters
# and return the executed stdout as a string
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)
end
@ -365,15 +474,19 @@ module OpenProject
# and calls the given block with in, out, err, thread
# from +Open3#popen3+.
def popen3(args, opt = {}, &block)
opt = opt.merge(chdir: checkout_path) if checkout?
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)
process = wait_thr.value
if process.exitstatus != 0
raise Exceptions::CommandFailed.new(
'git',
"git exited with non-zero status: #{process.exitstatus}"
%{
`git #{args.join(' ')}` exited with non-zero status:
#{process.exitstatus} (#{stderr.read})
}.squish
)
end
end
@ -390,12 +503,14 @@ module OpenProject
end
end
def build_git_cmd(args)
def build_git_cmd(args, opts = {})
if client_version_above?([1, 7, 2])
args.unshift('-c', 'core.quotepath=false')
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

@ -156,12 +156,16 @@ module OpenProject
# If the operation throws an exception or the operation yields a non-zero exit code
# we rethrow a +CommandFailed+ with a meaningful error message
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
error_msg = "SCM command failed: Non-zero exit code (#{code}) for `#{client_command}`"
logger.error(error_msg)
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
output

@ -73,11 +73,12 @@ module OpenProject
root_url.sub('file://', '')
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)
@login = login
@password = password
@identifier = identifier
end
def checkout_command

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

Loading…
Cancel
Save