Related work package: [#20415](https://community.openproject.org/work_packages/20415). This is a combined refactoring and extraction effort of local SCM functionality for SVM, Git adapters in preparation for the explicit lifetime management of repositories in #20416 and implementation of (local) repository storage quota in #18393. This PR is still heavily WIP and for the time being, depends on the **legacy specs** for testing. To run the tests, execute the following steps: ~~~ rake test:scm:setup:all rake spec:legacy ~~~ I'm open for ideas on how we integrate scm adapters tests into current specs. --- - [x] Exchange `IO::Open` for `Open3` to avoid countless manual exitcode checks - [x] Extract local functionality in its own Module - [] Extend `LocalClient` module with links to create/delete services for #20415 - [x] Break down unbearable local parse routines (Hound will be over the place until fixed - but down from 260 to < 20) - [x] `entries` - [x] `revisions` - [] local repository factory - [] Specs - [] Fix / Modernize - [] More adapter-related specspull/3196/head
parent
4a155d267e
commit
7ebac37c14
@ -0,0 +1,71 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
## |
||||
# Implements the asynchronous creation of a local repository. |
||||
class DeleteUserService < Struct.new :repository |
||||
## |
||||
# Deletes the given user if allowed. |
||||
# |
||||
# @return True if the user deletion has been initiated, false otherwise. |
||||
def call |
||||
if deletion_allowed? |
||||
# as destroying users is a lengthy process we handle it in the background |
||||
# and lock the account now so that no action can be performed with it |
||||
user.lock! |
||||
Delayed::Job.enqueue DeleteUserJob.new(user) |
||||
|
||||
logout! if self_delete? |
||||
|
||||
true |
||||
else |
||||
false |
||||
end |
||||
end |
||||
|
||||
## |
||||
# Checks if a given repository may be created and managed locally. |
||||
# |
||||
# @param repository [Repository] SCM repository to be created |
||||
def self.allowed?(repository) |
||||
enabled = config[:git] |
||||
if repository.managed_by_openproject? |
||||
Setting.users_deletable_by_self? |
||||
else |
||||
actor.admin && Setting.users_deletable_by_admins? |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
def self.config |
||||
OpenProject::Configuration[:scm].presence || {} |
||||
end |
||||
|
||||
end |
@ -0,0 +1,165 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
module OpenProject |
||||
module Scm |
||||
module Adapters |
||||
class CommandFailed < StandardError |
||||
attr_reader :program |
||||
attr_reader :command |
||||
attr_reader :message |
||||
|
||||
# Create a +CommandFailed+ exception for the executed program (e.g., 'svn'), |
||||
# the executed full command string (e.g., 'svn info --xml ...'), |
||||
# and a meaningful error message |
||||
# |
||||
# If the operation throws an exception or the operation we rethrow a |
||||
# +ShellError+ with a meaningful error message. |
||||
def initialize(program, command, message) |
||||
@program = program |
||||
@command = command |
||||
@message = message |
||||
end |
||||
|
||||
def to_s |
||||
"CommandFailed(#{@program}) -> #{@message}" |
||||
end |
||||
end |
||||
|
||||
# raised if scm command exited with error, e.g. unknown revision. |
||||
class ScmCommandAborted < CommandFailed |
||||
end |
||||
|
||||
class Entries < Array |
||||
def sort_by_name |
||||
sort do |x, y| |
||||
if x.kind == y.kind |
||||
x.name.to_s <=> y.name.to_s |
||||
else |
||||
x.kind <=> y.kind |
||||
end |
||||
end |
||||
end |
||||
|
||||
def revisions |
||||
Revisions.new(map(&:lastrev).compact) |
||||
end |
||||
end |
||||
|
||||
class Info |
||||
attr_accessor :root_url, :lastrev |
||||
def initialize(attributes = {}) |
||||
self.root_url = attributes[:root_url] if attributes[:root_url] |
||||
self.lastrev = attributes[:lastrev] |
||||
end |
||||
end |
||||
|
||||
class Entry |
||||
attr_accessor :name, :path, :kind, :size, :lastrev |
||||
def initialize(attributes = {}) |
||||
[:name, :path, :kind, :size].each do |attr| |
||||
send("#{attr}=", attributes[attr]) |
||||
end |
||||
|
||||
self.size = size.to_i if size.present? |
||||
self.lastrev = attributes[:lastrev] |
||||
end |
||||
|
||||
def file? |
||||
'file' == kind |
||||
end |
||||
|
||||
def dir? |
||||
'dir' == kind |
||||
end |
||||
|
||||
def text? |
||||
Redmine::MimeType.is_type?('text', name) |
||||
end |
||||
end |
||||
|
||||
class Revisions < Array |
||||
def latest |
||||
sort do |x, y| |
||||
if x.time.nil? or y.time.nil? |
||||
0 |
||||
else |
||||
x.time <=> y.time |
||||
end |
||||
end.last |
||||
end |
||||
end |
||||
|
||||
class Revision |
||||
attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch |
||||
attr_writer :identifier |
||||
|
||||
def initialize(attributes = {}) |
||||
[:identifier, :scmid, :author, :time, :paths, :revision, :branch].each do |attr| |
||||
send("#{attr}=", attributes[attr]) |
||||
end |
||||
|
||||
self.name = attributes[:name].presence || identifier |
||||
self.message = attributes[:message].presence || '' |
||||
end |
||||
|
||||
# Returns the identifier of this revision; see also Changeset model |
||||
def identifier |
||||
(@identifier || revision).to_s |
||||
end |
||||
|
||||
# Returns the readable identifier. |
||||
def format_identifier |
||||
identifier |
||||
end |
||||
end |
||||
|
||||
class Annotate |
||||
attr_reader :lines, :revisions |
||||
|
||||
def initialize |
||||
@lines = [] |
||||
@revisions = [] |
||||
end |
||||
|
||||
def add_line(line, revision) |
||||
@lines << line |
||||
@revisions << revision |
||||
end |
||||
|
||||
def content |
||||
lines.join("\n") |
||||
end |
||||
|
||||
def empty? |
||||
lines.empty? |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,160 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'open_project/scm/adapters' |
||||
|
||||
module OpenProject |
||||
module Scm |
||||
module Adapters |
||||
class Base |
||||
def initialize(url, root_url = nil) |
||||
@url = url |
||||
@root_url = root_url.blank? ? retrieve_root_url : root_url |
||||
end |
||||
|
||||
def local? |
||||
false |
||||
end |
||||
|
||||
def remote? |
||||
!local? |
||||
end |
||||
|
||||
def logger |
||||
Rails.logger |
||||
end |
||||
|
||||
def adapter_name |
||||
'Base' |
||||
end |
||||
|
||||
def root_url |
||||
@root_url |
||||
end |
||||
|
||||
def url |
||||
@url |
||||
end |
||||
|
||||
def info |
||||
nil |
||||
end |
||||
|
||||
# Returns the entry identified by path and revision identifier |
||||
# or nil if entry doesn't exist in the repository |
||||
def entry(path = nil, identifier = nil) |
||||
parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? } |
||||
search_entries(parts, identifier) |
||||
end |
||||
|
||||
def search_entries(parts, identifier) |
||||
search_path = parts[0..-2].join('/') |
||||
search_name = parts[-1] |
||||
|
||||
if search_path.blank? && search_name.blank? |
||||
# Root entry |
||||
Entry.new(path: '', kind: 'dir') |
||||
else |
||||
# Search for the entry in the parent directory |
||||
es = entries(search_path, identifier) |
||||
es ? es.detect { |e| e.name == search_name } : nil |
||||
end |
||||
end |
||||
|
||||
# Returns an Entries collection |
||||
# or nil if the given path doesn't exist in the repository |
||||
def entries(_path = nil, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def branches |
||||
nil |
||||
end |
||||
|
||||
def tags |
||||
nil |
||||
end |
||||
|
||||
def default_branch |
||||
nil |
||||
end |
||||
|
||||
def properties(_path, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {}) |
||||
nil |
||||
end |
||||
|
||||
def diff(_path, _identifier_from, _identifier_to = nil) |
||||
nil |
||||
end |
||||
|
||||
def cat(_path, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def with_leading_slash(path) |
||||
path ||= '' |
||||
(path[0, 1] != '/') ? "/#{path}" : path |
||||
end |
||||
|
||||
def with_trailling_slash(path) |
||||
path ||= '' |
||||
(path[-1, 1] == '/') ? path : "#{path}/" |
||||
end |
||||
|
||||
def without_leading_slash(path) |
||||
path ||= '' |
||||
path.gsub(%r{\A/+}, '') |
||||
end |
||||
|
||||
def without_trailling_slash(path) |
||||
path ||= '' |
||||
(path[-1, 1] == '/') ? path[0..-2] : path |
||||
end |
||||
|
||||
def retrieve_root_url |
||||
info = self.info |
||||
info ? info.root_url : nil |
||||
end |
||||
end |
||||
|
||||
class Info |
||||
attr_accessor :root_url, :lastrev |
||||
|
||||
def initialize(attributes = {}) |
||||
self.root_url = attributes[:root_url] if attributes[:root_url] |
||||
self.lastrev = attributes[:lastrev] |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,371 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require_dependency 'open_project/scm/adapters' |
||||
|
||||
module OpenProject |
||||
module Scm |
||||
module Adapters |
||||
class Git < Base |
||||
include LocalClient |
||||
|
||||
SCM_GIT_REPORT_LAST_COMMIT = true |
||||
|
||||
def initialize(url, root_url = nil, _login = nil, _password = nil, path_encoding = nil) |
||||
super(url, root_url) |
||||
@flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT |
||||
@path_encoding = path_encoding.presence || 'UTF-8' |
||||
end |
||||
|
||||
def client_command |
||||
@client_command ||= scm_config[:git_client_command]|| 'git' |
||||
end |
||||
|
||||
def client_version |
||||
@client_version ||= (git_binary_version || []) |
||||
end |
||||
|
||||
def scm_version_from_command_line |
||||
capture_out(%w[--version --no-color]) |
||||
end |
||||
|
||||
def git_binary_version |
||||
scm_version = scm_version_from_command_line.dup |
||||
if scm_version.respond_to?(:force_encoding) |
||||
scm_version.force_encoding('ASCII-8BIT') |
||||
end |
||||
m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) |
||||
unless m.nil? |
||||
m[2].scan(%r{\d+}).map(&:to_i) |
||||
end |
||||
end |
||||
|
||||
def info |
||||
Info.new(root_url: url, lastrev: lastrev('', nil)) |
||||
end |
||||
|
||||
def branches |
||||
return @branches if @branches |
||||
@branches = [] |
||||
cmd_args = %w|branch --no-color| |
||||
popen3(cmd_args) do |io| |
||||
io.each_line do |line| |
||||
@branches << line.match('\s*\*?\s*(.*)$')[1] |
||||
end |
||||
end |
||||
@branches.sort! |
||||
end |
||||
|
||||
def tags |
||||
return @tags if @tags |
||||
cmd_args = %w|tag| |
||||
@tags = capture_git(cmd_args).lines.sort!.map(&:strip) |
||||
end |
||||
|
||||
def default_branch |
||||
bras = branches |
||||
return nil if bras.nil? |
||||
bras.include?('master') ? 'master' : bras.first |
||||
end |
||||
|
||||
def entries(path, identifier = nil) |
||||
entries = Entries.new |
||||
path = scm_encode(@path_encoding, 'UTF-8', path) |
||||
args = %w|ls-tree -l| |
||||
args << "HEAD:#{path}" if identifier.nil? |
||||
args << "#{identifier}:#{path}" if identifier |
||||
|
||||
parse_by_line(args, binmode: true) do |line| |
||||
e = parse_entry(line, path, identifier) |
||||
entries << e unless entries.detect { |entry| entry.name == e.name } |
||||
end |
||||
|
||||
entries.sort_by_name |
||||
end |
||||
|
||||
def parse_entry(line, path, identifier) |
||||
if line.chomp =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/ |
||||
type = $1 |
||||
size = $3 |
||||
name = $4.force_encoding(@path_encoding) |
||||
path = encode_full_path(name, path) |
||||
|
||||
Entry.new( |
||||
name: scm_encode('UTF-8', @path_encoding, name), |
||||
path: path, |
||||
kind: (type == 'tree') ? 'dir' : 'file', |
||||
size: (type == 'tree') ? nil : size, |
||||
lastrev: @flag_report_last_commit ? lastrev(path, identifier) : Revision.new |
||||
) |
||||
end |
||||
end |
||||
|
||||
def encode_full_path(name, path) |
||||
full_path = path.empty? ? name : "#{path}/#{name}" |
||||
scm_encode('UTF-8', @path_encoding, full_path) |
||||
end |
||||
|
||||
def lastrev(path, rev) |
||||
return nil if path.nil? |
||||
args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1| |
||||
args << rev if rev |
||||
args << '--' << path unless path.empty? |
||||
lines = capture_git(args).lines |
||||
begin |
||||
build_lastrev(lines) |
||||
rescue NoMethodError |
||||
logger.error("The revision '#{path}' has a wrong format") |
||||
return nil |
||||
end |
||||
end |
||||
|
||||
def build_lastrev(lines) |
||||
id = lines[0].split[1] |
||||
author = lines[1].match('Author:\s+(.*)$')[1] |
||||
time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1]) |
||||
|
||||
Revision.new( |
||||
identifier: id, |
||||
scmid: id, |
||||
author: author, |
||||
time: time, |
||||
message: nil, |
||||
paths: nil |
||||
) |
||||
end |
||||
|
||||
def revisions(path, identifier_from, identifier_to, options = {}) |
||||
revisions = Revisions.new |
||||
args = build_revision_args(path, identifier_from, identifier_to, options) |
||||
|
||||
files = [] |
||||
changeset = {} |
||||
parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files |
||||
parse_by_line(args, binmode: true) do |line| |
||||
if line =~ /^commit ([0-9a-f]{40})$/ |
||||
key = 'commit' |
||||
value = $1 |
||||
if parsing_descr == 1 || parsing_descr == 2 |
||||
parsing_descr = 0 |
||||
revision = Revision.new( |
||||
identifier: changeset[:commit], |
||||
scmid: changeset[:commit], |
||||
author: changeset[:author], |
||||
time: Time.parse(changeset[:date]), |
||||
message: changeset[:description], |
||||
paths: files |
||||
) |
||||
if block_given? |
||||
yield revision |
||||
else |
||||
revisions << revision |
||||
end |
||||
changeset = {} |
||||
files = [] |
||||
end |
||||
changeset[:commit] = $1 |
||||
elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/ |
||||
key = $1 |
||||
value = $2 |
||||
if key == 'Author' |
||||
changeset[:author] = value |
||||
elsif key == 'CommitDate' |
||||
changeset[:date] = value |
||||
end |
||||
elsif (parsing_descr == 0) && line.chomp.to_s == '' |
||||
parsing_descr = 1 |
||||
changeset[:description] = '' |
||||
elsif (parsing_descr == 1 || parsing_descr == 2) && |
||||
(line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/) |
||||
|
||||
parsing_descr = 2 |
||||
fileaction = $1 |
||||
filepath = $2 |
||||
p = scm_encode('UTF-8', @path_encoding, filepath) |
||||
files << { action: fileaction, path: p } |
||||
elsif (parsing_descr == 1 || parsing_descr == 2) && |
||||
(line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/) |
||||
|
||||
parsing_descr = 2 |
||||
fileaction = $1 |
||||
filepath = $3 |
||||
p = scm_encode('UTF-8', @path_encoding, filepath) |
||||
files << { action: fileaction, path: p } |
||||
elsif (parsing_descr == 1) && line.chomp.to_s == '' |
||||
parsing_descr = 2 |
||||
elsif parsing_descr == 1 |
||||
changeset[:description] << line[4..-1] |
||||
end |
||||
end |
||||
|
||||
if changeset[:commit] |
||||
revision = Revision.new( |
||||
identifier: changeset[:commit], |
||||
scmid: changeset[:commit], |
||||
author: changeset[:author], |
||||
time: Time.parse(changeset[:date]), |
||||
message: changeset[:description], |
||||
paths: files |
||||
) |
||||
|
||||
if block_given? |
||||
yield revision |
||||
else |
||||
revisions << revision |
||||
end |
||||
end |
||||
|
||||
revisions |
||||
end |
||||
|
||||
def build_revision_args(path, identifier_from, identifier_to, options) |
||||
args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller| |
||||
args << '--reverse' if options[:reverse] |
||||
args << '--all' if options[:all] |
||||
args << '-n' << "#{options[:limit].to_i}" if options[:limit] |
||||
from_to = '' |
||||
from_to << "#{identifier_from}.." if identifier_from |
||||
from_to << "#{identifier_to}" if identifier_to |
||||
args << from_to if from_to.present? |
||||
args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since] |
||||
args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty? |
||||
|
||||
args |
||||
end |
||||
|
||||
def diff(path, identifier_from, identifier_to = nil) |
||||
args = [] |
||||
if identifier_to |
||||
args << 'diff' << '--no-color' << identifier_to << identifier_from |
||||
else |
||||
args << 'show' << '--no-color' << identifier_from |
||||
end |
||||
args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty? |
||||
capture_git(args).lines.map(&:chomp) |
||||
rescue CommandFailed |
||||
nil |
||||
end |
||||
|
||||
def annotate(path, identifier = nil) |
||||
identifier = 'HEAD' if identifier.blank? |
||||
args = %w|blame| |
||||
args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path) |
||||
blame = Annotate.new |
||||
content = capture_git(args, binmode: true) |
||||
|
||||
if content.respond_to?(:force_encoding) && |
||||
(content.dup.force_encoding('UTF-8') != content.dup.force_encoding('BINARY')) |
||||
|
||||
# Ruby 1.9.2 |
||||
# TODO: need to handle edge cases of non-binary content that isn't UTF-8 |
||||
return nil |
||||
end |
||||
|
||||
identifier = '' |
||||
# git shows commit author on the first occurrence only |
||||
authors_by_commit = {} |
||||
content.split("\n").each do |line| |
||||
if line =~ /^([0-9a-f]{39,40})\s.*/ |
||||
identifier = $1 |
||||
elsif line =~ /^author (.+)/ |
||||
authors_by_commit[identifier] = $1.strip |
||||
elsif line =~ /^\t(.*)/ |
||||
blame.add_line($1, Revision.new( |
||||
identifier: identifier, |
||||
author: authors_by_commit[identifier])) |
||||
identifier = '' |
||||
end |
||||
end |
||||
blame |
||||
end |
||||
|
||||
def cat(path, identifier = nil) |
||||
if identifier.nil? |
||||
identifier = 'HEAD' |
||||
end |
||||
args = %w|show --no-color| |
||||
args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}" |
||||
capture_git(args, binmode: true) |
||||
end |
||||
|
||||
class Revision < OpenProject::Scm::Adapters::Revision |
||||
# Returns the readable identifier |
||||
def format_identifier |
||||
identifier[0, 8] |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
## |
||||
# 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) |
||||
capture_out(cmd, opt) |
||||
end |
||||
|
||||
## |
||||
# Builds the full git arguments from the parameters |
||||
# and calls the given block with in, out, err, thread |
||||
# from +Open3#popen3+. |
||||
def popen3(args, opt = {}, &block) |
||||
cmd = build_git_cmd(args) |
||||
super(cmd, opt) do |_stdin, stdout, _stderr, wait_thr| |
||||
block.call(stdout) |
||||
|
||||
process = wait_thr.value |
||||
if process.exitstatus != 0 |
||||
raise CommandFailed, "git exited with non-zero status: #{$?.exitstatus}" |
||||
end |
||||
end |
||||
end |
||||
|
||||
## |
||||
# Runs the given arguments through git |
||||
# and processes the result line by line. |
||||
# |
||||
def parse_by_line(cmd, opts = {}, &block) |
||||
popen3(cmd) do |io| |
||||
io.binmode if opts[:binmode] |
||||
io.each_line &block |
||||
end |
||||
end |
||||
|
||||
def build_git_cmd(args) |
||||
if client_version_above?([1, 7, 2]) |
||||
args.unshift('-c', 'core.quotepath=false') |
||||
end |
||||
|
||||
args.unshift('--git-dir', (root_url || url)) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,163 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'open3' |
||||
module OpenProject |
||||
module Scm |
||||
module Adapters |
||||
module LocalClient |
||||
## |
||||
# Determines local capabilities for SCM creation. |
||||
# Overridden by including classes when SCM may be remote. |
||||
def local? |
||||
true |
||||
end |
||||
|
||||
## |
||||
# Reads the configuration for this strategy from OpenProject's `configuration.yml`. |
||||
def scm_config |
||||
OpenProject::Configuration[:scm] |
||||
%w(scm global_basic_auth).inject(config) do |acc, key| |
||||
HashWithIndifferentAccess.new acc[key] |
||||
end |
||||
end |
||||
|
||||
## |
||||
# client executable command |
||||
def client_command |
||||
'' |
||||
end |
||||
|
||||
def client_available |
||||
!client_version.empty? |
||||
end |
||||
|
||||
## |
||||
# Returns the version of the scm client |
||||
# Eg: [1, 5, 0] or [] if unknown |
||||
def client_version |
||||
[] |
||||
end |
||||
|
||||
## |
||||
# Returns the version string of the scm client |
||||
# Eg: '1.5.0' or 'Unknown version' if unknown |
||||
def client_version_string |
||||
v = client_version || 'Unknown version' |
||||
v.is_a?(Array) ? v.join('.') : v.to_s |
||||
end |
||||
|
||||
## |
||||
# Returns true if the current client version is above |
||||
# or equals the given one |
||||
# If option is :unknown is set to true, it will return |
||||
# true if the client version is unknown |
||||
def client_version_above?(v, options = {}) |
||||
((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown]) |
||||
end |
||||
|
||||
def shell_quote(str) |
||||
Shellwords.escape(str) |
||||
end |
||||
|
||||
def supports_cat? |
||||
true |
||||
end |
||||
|
||||
def supports_annotate? |
||||
respond_to?('annotate') |
||||
end |
||||
|
||||
def target(path = '') |
||||
base = path.match(/\A\//) ? root_url : url |
||||
shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, '')) |
||||
end |
||||
|
||||
# Executes the given arguments for +client_command+ on the shell |
||||
# and returns the resulting stdout. |
||||
# |
||||
# May optionally specify an opts hash with flags for popen3 and Process.spawn |
||||
# (cf., :binmode, :stdin_data in +Open3.capture3+) |
||||
# |
||||
# 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]) |
||||
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 CommandFailed.new(client_command, stripped_command(args), error_msg) |
||||
end |
||||
|
||||
output |
||||
end |
||||
|
||||
# Executes the given arguments for +client_command+ on the shell |
||||
# and returns stdout, stderr, and the exit code. |
||||
# |
||||
# If the operation throws an exception or the operation we rethrow a |
||||
# +CommandFailed+ with a meaningful error message. |
||||
def popen3(args, opts = {}, &block) |
||||
logger.debug "Shelling out: `#{stripped_command(args)}`" |
||||
Open3.popen3(client_command, *args, opts, &block) |
||||
rescue => e |
||||
error_msg = "SCM command for `#{client_command}` failed: #{strip_credential(e.message)}" |
||||
logger.error(error_msg) |
||||
raise CommandFailed.new(client_command, stripped_command(args), error_msg) |
||||
end |
||||
|
||||
## |
||||
# Returns the full client command and args with stripped credentials |
||||
def stripped_command(args) |
||||
"#{client_command} #{strip_credential(args.join(' '))}" |
||||
end |
||||
|
||||
## |
||||
# Replaces argument values for --username/--password in a given command |
||||
# with a placeholder |
||||
def strip_credential(cmd) |
||||
q = Redmine::Platform.mswin? ? '"' : "'" |
||||
cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx') |
||||
end |
||||
|
||||
def scm_encode(to, from, str) |
||||
return nil if str.nil? |
||||
return str if to == from |
||||
begin |
||||
str.to_s.encode(to, from) |
||||
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err |
||||
logger.error("failed to convert from #{from} to #{to}. #{err}") |
||||
nil |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,282 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'uri' |
||||
|
||||
module OpenProject |
||||
module Scm |
||||
module Adapters |
||||
class Subversion < Base |
||||
include LocalClient |
||||
|
||||
def client_command |
||||
@client_command ||= scm_config[:subversion_client_command] || 'svn' |
||||
end |
||||
|
||||
def client_version |
||||
@client_version ||= (svn_binary_version || []) |
||||
end |
||||
|
||||
def svn_binary_version |
||||
scm_version = scm_version_from_command_line.dup |
||||
m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) |
||||
if m |
||||
m[2].scan(%r{\d+}).map(&:to_i) |
||||
end |
||||
end |
||||
|
||||
def scm_version_from_command_line |
||||
capture_out('--version') |
||||
end |
||||
|
||||
def initialize(url, root_url = nil, login = nil, password = nil, _path_encoding = nil) |
||||
super(url, root_url) |
||||
@login = login |
||||
@password = password |
||||
end |
||||
|
||||
# Get info about the svn repository |
||||
def info |
||||
cmd = build_svn_cmd(['info', '--xml', target]) |
||||
xml_capture(cmd, force_encoding: true) do |doc| |
||||
Info.new( |
||||
root_url: doc.xpath('/info/entry/repository/root').text, |
||||
lastrev: extract_revision(doc.at_xpath('/info/entry/commit')) |
||||
) |
||||
end |
||||
end |
||||
|
||||
def entries(path = nil, identifier = nil) |
||||
path ||= '' |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
entries = Entries.new |
||||
cmd = ['list', '--xml', "#{target(path)}@#{identifier}"] |
||||
xml_capture(cmd, force_encoding: true) do |doc| |
||||
doc.xpath('/lists/list/entry').each { |list| entries << extract_entry(list, path) } |
||||
end |
||||
entries.sort_by_name |
||||
end |
||||
|
||||
def properties(path, identifier = nil) |
||||
# proplist xml output supported in svn 1.5.0 and higher |
||||
return nil unless client_version_above?([1, 5, 0]) |
||||
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = ['proplist', '--verbose', '--xml', "#{target(path)}@#{identifier}"] |
||||
properties = {} |
||||
xml_capture(cmd, force_encoding: true) do |doc| |
||||
doc.xpath('/properties/target/property').each do |prop| |
||||
properties[prop['name']] = prop.text |
||||
end |
||||
end |
||||
|
||||
properties |
||||
end |
||||
|
||||
def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {}) |
||||
revisions = Revisions.new |
||||
fetch_revision_entries(identifier_from, identifier_to, options, path) do |logentry| |
||||
paths = logentry.xpath('paths/path').map { |entry| build_path(entry) } |
||||
paths.sort! { |x, y| x[:path] <=> y[:path] } |
||||
|
||||
r = extract_revision(logentry) |
||||
r.paths = paths |
||||
|
||||
revisions << r |
||||
end |
||||
revisions |
||||
end |
||||
|
||||
def diff(path, identifier_from, identifier_to = nil, _type = 'inline') |
||||
path ||= '' |
||||
|
||||
identifier_from = numeric_identifier(identifier_from) |
||||
identifier_to = numeric_identifier(identifier_to, identifier_from - 1) |
||||
|
||||
cmd = ['diff', '-r', "#{identifier_to}:#{identifier_from}", |
||||
"#{target(path)}@#{identifier_from}"] |
||||
capture_svn(cmd).lines.map(&:chomp) |
||||
end |
||||
|
||||
def numeric_identifier(identifier, default = '') |
||||
if identifier && identifier.to_i > 0 |
||||
identifier.to_i |
||||
else |
||||
default |
||||
end |
||||
end |
||||
|
||||
def cat(path, identifier = nil) |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = ['cat', "#{target(path)}@#{identifier}"] |
||||
capture_svn(cmd) |
||||
end |
||||
|
||||
def annotate(path, identifier = nil) |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = ['blame', "#{target(path)}@#{identifier}"] |
||||
blame = Annotate.new |
||||
popen3(cmd) do |io| |
||||
io.each_line do |line| |
||||
next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$} |
||||
blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip)) |
||||
end |
||||
end |
||||
blame |
||||
end |
||||
|
||||
private |
||||
|
||||
## |
||||
# Builds the SVM command arguments around the given parameters |
||||
# Appends to the parameter: |
||||
# --username, --password if specified for this repository |
||||
# --no-auth-cache force re-authentication |
||||
# --non-interactive avoid prompts |
||||
def build_svn_cmd(args) |
||||
if @login.present? |
||||
args.push('--username', shell_quote(@login)) |
||||
args.push('--password', shell_quote(@password)) if @password.present? |
||||
end |
||||
|
||||
args.push('--no-auth-cache', '--non-interactive') |
||||
end |
||||
|
||||
def xml_capture(cmd, opts = {}) |
||||
output = capture_svn(cmd, opts) |
||||
doc = Nokogiri::XML(output) |
||||
|
||||
# Yield helper methods instead of doc |
||||
yield doc |
||||
end |
||||
|
||||
def extract_entry(entry, path) |
||||
revision = extract_revision(entry.at_xpath('commit')) |
||||
kind, size, name = parse_entry(entry) |
||||
|
||||
# Skip directory if there is no commit date (usually that |
||||
# means that we don't have read access to it) |
||||
return if kind == 'dir' && revision.time.nil? |
||||
|
||||
Entry.new( |
||||
name: URI.unescape(name), |
||||
path: ((path.empty? ? '' : "#{path}/") + name), |
||||
kind: kind, |
||||
size: size.nil? ? nil : size.to_i, |
||||
lastrev: revision |
||||
) |
||||
end |
||||
|
||||
def parse_entry(entry) |
||||
kind = entry['kind'] |
||||
size = entry.xpath('size').text |
||||
name = entry.xpath('name').text |
||||
|
||||
[kind, size, name] |
||||
end |
||||
|
||||
def build_path(entry) |
||||
{ |
||||
action: entry['action'], |
||||
path: entry.text, |
||||
from_path: entry['copyfrom-path'], |
||||
from_revision: entry['copyfrom-rev'] |
||||
} |
||||
end |
||||
|
||||
def extract_revision(commit_node) |
||||
# We may be unauthorized to read the commit date |
||||
date = |
||||
begin |
||||
Time.parse(commit_node.xpath('date').text).localtime |
||||
rescue ArgumentError |
||||
nil |
||||
end |
||||
|
||||
Revision.new( |
||||
identifier: commit_node['revision'], |
||||
time: date, |
||||
message: commit_node.xpath('msg').text, |
||||
author: commit_node.xpath('author').text |
||||
) |
||||
end |
||||
|
||||
def fetch_revision_entries(identifier_from, identifier_to, options, path, &block) |
||||
path ||= '' |
||||
identifier_from = numeric_identifier(identifier_from, 'HEAD') |
||||
identifier_to = numeric_identifier(identifier_to, 1) |
||||
cmd = ['log', '--xml', '-r', "#{identifier_from}:#{identifier_to}"] |
||||
cmd << '--verbose' if options[:with_paths] |
||||
cmd << '--limit' << options[:limit].to_s if options[:limit] |
||||
cmd << target(path) |
||||
xml_capture(cmd, force_encoding: true) do |doc| |
||||
doc.xpath('/log/logentry').each &block |
||||
end |
||||
end |
||||
|
||||
def target(path = '') |
||||
base = path.match(/\A\//) ? root_url : url |
||||
uri = "#{base}/#{path}" |
||||
URI.escape(URI.escape(uri), '[]') |
||||
# shell_quote(uri.gsub(/[?<>\*]/, '')) |
||||
end |
||||
|
||||
## |
||||
# Builds the full git arguments from the parameters |
||||
# and return the executed stdout as a string |
||||
def capture_svn(args, opt = {}) |
||||
cmd = build_svn_cmd(args) |
||||
output = capture_out(cmd) |
||||
|
||||
if opt[:force_encoding] && output.respond_to?(:force_encoding) |
||||
output.force_encoding('UTF-8') |
||||
end |
||||
|
||||
output |
||||
end |
||||
|
||||
## |
||||
# Builds the full git arguments from the parameters |
||||
# and calls the given block with in, out, err, thread |
||||
# from +Open3#popen3+. |
||||
def popen3(args, &block) |
||||
cmd = build_svn_cmd(args) |
||||
super(cmd) do |_stdin, stdout, _stderr, wait_thr| |
||||
block.call(stdout) |
||||
|
||||
process = wait_thr.value |
||||
if process.exitstatus != 0 |
||||
raise CommandFailed, "git exited with non-zero status: #{$?.exitstatus}" |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,365 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'cgi' |
||||
|
||||
module Redmine |
||||
module Scm |
||||
module Adapters |
||||
class CommandFailed < StandardError #:nodoc: |
||||
end |
||||
|
||||
class AbstractAdapter #:nodoc: |
||||
class << self |
||||
def client_command |
||||
'' |
||||
end |
||||
|
||||
# Returns the version of the scm client |
||||
# Eg: [1, 5, 0] or [] if unknown |
||||
def client_version |
||||
[] |
||||
end |
||||
|
||||
# Returns the version string of the scm client |
||||
# Eg: '1.5.0' or 'Unknown version' if unknown |
||||
def client_version_string |
||||
v = client_version || 'Unknown version' |
||||
v.is_a?(Array) ? v.join('.') : v.to_s |
||||
end |
||||
|
||||
# Returns true if the current client version is above |
||||
# or equals the given one |
||||
# If option is :unknown is set to true, it will return |
||||
# true if the client version is unknown |
||||
def client_version_above?(v, options = {}) |
||||
((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown]) |
||||
end |
||||
|
||||
def client_available |
||||
true |
||||
end |
||||
|
||||
def shell_quote(str) |
||||
if Redmine::Platform.mswin? |
||||
'"' + str.gsub(/"/, '\\"') + '"' |
||||
else |
||||
"'" + str.gsub(/'/, "'\"'\"'") + "'" |
||||
end |
||||
end |
||||
end |
||||
|
||||
def initialize(url, root_url = nil, login = nil, password = nil, |
||||
_path_encoding = nil) |
||||
@url = url |
||||
@login = login if login && !login.empty? |
||||
@password = (password || '') if @login |
||||
@root_url = root_url.blank? ? retrieve_root_url : root_url |
||||
end |
||||
|
||||
def adapter_name |
||||
'Abstract' |
||||
end |
||||
|
||||
def supports_cat? |
||||
true |
||||
end |
||||
|
||||
def supports_annotate? |
||||
respond_to?('annotate') |
||||
end |
||||
|
||||
def root_url |
||||
@root_url |
||||
end |
||||
|
||||
def url |
||||
@url |
||||
end |
||||
|
||||
# get info about the svn repository |
||||
def info |
||||
nil |
||||
end |
||||
|
||||
# Returns the entry identified by path and revision identifier |
||||
# or nil if entry doesn't exist in the repository |
||||
def entry(path = nil, identifier = nil) |
||||
parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? } |
||||
search_path = parts[0..-2].join('/') |
||||
search_name = parts[-1] |
||||
if search_path.blank? && search_name.blank? |
||||
# Root entry |
||||
Entry.new(path: '', kind: 'dir') |
||||
else |
||||
# Search for the entry in the parent directory |
||||
es = entries(search_path, identifier) |
||||
es ? es.detect { |e| e.name == search_name } : nil |
||||
end |
||||
end |
||||
|
||||
# Returns an Entries collection |
||||
# or nil if the given path doesn't exist in the repository |
||||
def entries(_path = nil, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def branches |
||||
nil |
||||
end |
||||
|
||||
def tags |
||||
nil |
||||
end |
||||
|
||||
def default_branch |
||||
nil |
||||
end |
||||
|
||||
def properties(_path, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {}) |
||||
nil |
||||
end |
||||
|
||||
def diff(_path, _identifier_from, _identifier_to = nil) |
||||
nil |
||||
end |
||||
|
||||
def cat(_path, _identifier = nil) |
||||
nil |
||||
end |
||||
|
||||
def with_leading_slash(path) |
||||
path ||= '' |
||||
(path[0, 1] != '/') ? "/#{path}" : path |
||||
end |
||||
|
||||
def with_trailling_slash(path) |
||||
path ||= '' |
||||
(path[-1, 1] == '/') ? path : "#{path}/" |
||||
end |
||||
|
||||
def without_leading_slash(path) |
||||
path ||= '' |
||||
path.gsub(%r{\A/+}, '') |
||||
end |
||||
|
||||
def without_trailling_slash(path) |
||||
path ||= '' |
||||
(path[-1, 1] == '/') ? path[0..-2] : path |
||||
end |
||||
|
||||
def shell_quote(str) |
||||
self.class.shell_quote(str) |
||||
end |
||||
|
||||
private |
||||
|
||||
def retrieve_root_url |
||||
info = self.info |
||||
info ? info.root_url : nil |
||||
end |
||||
|
||||
def target(path) |
||||
path ||= '' |
||||
base = path.match(/\A\//) ? root_url : url |
||||
shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, '')) |
||||
end |
||||
|
||||
def logger |
||||
self.class.logger |
||||
end |
||||
|
||||
def shellout(cmd, &block) |
||||
self.class.shellout(cmd, &block) |
||||
end |
||||
|
||||
def self.logger |
||||
Rails.logger |
||||
end |
||||
|
||||
def self.shellout(cmd, &block) |
||||
logger.debug "Shelling out: #{strip_credential(cmd)}" if logger && logger.debug? |
||||
if Rails.env == 'development' |
||||
# Capture stderr when running in dev environment |
||||
cmd = "#{cmd} 2>>#{Rails.root}/log/scm.stderr.log" |
||||
end |
||||
begin |
||||
if RUBY_VERSION < '1.9' |
||||
mode = 'r+' |
||||
else |
||||
mode = 'r+:ASCII-8BIT' |
||||
end |
||||
IO.popen(cmd, mode) do |io| |
||||
io.close_write |
||||
block.call(io) if block_given? |
||||
end |
||||
rescue Errno::ENOENT => e |
||||
msg = strip_credential(e.message) |
||||
# The command failed, log it and re-raise |
||||
logger.error("SCM command failed, make sure that your SCM binary (eg. svn) is in PATH (#{ENV['PATH']}): #{strip_credential(cmd)}\n with: #{msg}") |
||||
raise CommandFailed.new(msg) |
||||
end |
||||
end |
||||
|
||||
# Hides username/password in a given command |
||||
def self.strip_credential(cmd) |
||||
q = (Redmine::Platform.mswin? ? '"' : "'") |
||||
cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx') |
||||
end |
||||
|
||||
def strip_credential(cmd) |
||||
self.class.strip_credential(cmd) |
||||
end |
||||
|
||||
def scm_encode(to, from, str) |
||||
return nil if str.nil? |
||||
return str if to == from |
||||
begin |
||||
str.to_s.encode(to, from) |
||||
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err |
||||
logger.error("failed to convert from #{from} to #{to}. #{err}") |
||||
nil |
||||
end |
||||
end |
||||
end |
||||
|
||||
class Entries < Array |
||||
def sort_by_name |
||||
sort {|x, y| |
||||
if x.kind == y.kind |
||||
x.name.to_s <=> y.name.to_s |
||||
else |
||||
x.kind <=> y.kind |
||||
end |
||||
} |
||||
end |
||||
|
||||
def revisions |
||||
revisions ||= Revisions.new(collect(&:lastrev).compact) |
||||
end |
||||
end |
||||
|
||||
class Info |
||||
attr_accessor :root_url, :lastrev |
||||
def initialize(attributes = {}) |
||||
self.root_url = attributes[:root_url] if attributes[:root_url] |
||||
self.lastrev = attributes[:lastrev] |
||||
end |
||||
end |
||||
|
||||
class Entry |
||||
attr_accessor :name, :path, :kind, :size, :lastrev |
||||
def initialize(attributes = {}) |
||||
self.name = attributes[:name] if attributes[:name] |
||||
self.path = attributes[:path] if attributes[:path] |
||||
self.kind = attributes[:kind] if attributes[:kind] |
||||
self.size = attributes[:size].to_i if attributes[:size] |
||||
self.lastrev = attributes[:lastrev] |
||||
end |
||||
|
||||
def is_file? |
||||
'file' == kind |
||||
end |
||||
|
||||
def is_dir? |
||||
'dir' == kind |
||||
end |
||||
|
||||
def is_text? |
||||
Redmine::MimeType.is_type?('text', name) |
||||
end |
||||
end |
||||
|
||||
class Revisions < Array |
||||
def latest |
||||
sort {|x, y| |
||||
unless x.time.nil? or y.time.nil? |
||||
x.time <=> y.time |
||||
else |
||||
0 |
||||
end |
||||
}.last |
||||
end |
||||
end |
||||
|
||||
class Revision |
||||
attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch |
||||
attr_writer :identifier |
||||
|
||||
def initialize(attributes = {}) |
||||
self.identifier = attributes[:identifier] |
||||
self.scmid = attributes[:scmid] |
||||
self.name = attributes[:name] || identifier |
||||
self.author = attributes[:author] |
||||
self.time = attributes[:time] |
||||
self.message = attributes[:message] || '' |
||||
self.paths = attributes[:paths] |
||||
self.revision = attributes[:revision] |
||||
self.branch = attributes[:branch] |
||||
end |
||||
|
||||
# Returns the identifier of this revision; see also Changeset model |
||||
def identifier |
||||
(@identifier || revision).to_s |
||||
end |
||||
|
||||
# Returns the readable identifier. |
||||
def format_identifier |
||||
identifier |
||||
end |
||||
end |
||||
|
||||
class Annotate |
||||
attr_reader :lines, :revisions |
||||
|
||||
def initialize |
||||
@lines = [] |
||||
@revisions = [] |
||||
end |
||||
|
||||
def add_line(line, revision) |
||||
@lines << line |
||||
@revisions << revision |
||||
end |
||||
|
||||
def content |
||||
content = lines.join("\n") |
||||
end |
||||
|
||||
def empty? |
||||
lines.empty? |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,371 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter' |
||||
|
||||
module Redmine |
||||
module Scm |
||||
module Adapters |
||||
class GitAdapter < AbstractAdapter |
||||
SCM_GIT_REPORT_LAST_COMMIT = true |
||||
|
||||
# Git executable name |
||||
GIT_BIN = OpenProject::Configuration['scm_git_command'] || 'git' |
||||
|
||||
# raised if scm command exited with error, e.g. unknown revision. |
||||
class ScmCommandAborted < CommandFailed; end |
||||
|
||||
class << self |
||||
def client_command |
||||
@@bin ||= GIT_BIN |
||||
end |
||||
|
||||
def sq_bin |
||||
@@sq_bin ||= shell_quote(GIT_BIN) |
||||
end |
||||
|
||||
def client_version |
||||
@@client_version ||= (scm_command_version || []) |
||||
end |
||||
|
||||
def client_available |
||||
!client_version.empty? |
||||
end |
||||
|
||||
def scm_command_version |
||||
scm_version = scm_version_from_command_line.dup |
||||
if scm_version.respond_to?(:force_encoding) |
||||
scm_version.force_encoding('ASCII-8BIT') |
||||
end |
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) |
||||
m[2].scan(%r{\d+}).map(&:to_i) |
||||
end |
||||
end |
||||
|
||||
def scm_version_from_command_line |
||||
shellout("#{sq_bin} --version --no-color") { |io| io.read }.to_s |
||||
end |
||||
end |
||||
|
||||
def initialize(url, root_url = nil, login = nil, password = nil, path_encoding = nil) |
||||
super |
||||
@path_encoding = path_encoding || 'UTF-8' |
||||
@flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT |
||||
end |
||||
|
||||
def info |
||||
Info.new(root_url: url, lastrev: lastrev('', nil)) |
||||
rescue |
||||
nil |
||||
end |
||||
|
||||
def branches |
||||
return @branches if @branches |
||||
@branches = [] |
||||
cmd_args = %w|branch --no-color| |
||||
scm_cmd(*cmd_args) do |io| |
||||
io.each_line do |line| |
||||
@branches << line.match('\s*\*?\s*(.*)$')[1] |
||||
end |
||||
end |
||||
@branches.sort! |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def tags |
||||
return @tags if @tags |
||||
cmd_args = %w|tag| |
||||
scm_cmd(*cmd_args) do |io| |
||||
@tags = io.readlines.sort!.map(&:strip) |
||||
end |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def default_branch |
||||
bras = branches |
||||
return nil if bras.nil? |
||||
bras.include?('master') ? 'master' : bras.first |
||||
end |
||||
|
||||
def entries(path = nil, identifier = nil) |
||||
path ||= '' |
||||
p = scm_encode(@path_encoding, 'UTF-8', path) |
||||
entries = Entries.new |
||||
cmd_args = %w|ls-tree -l| |
||||
cmd_args << "HEAD:#{p}" if identifier.nil? |
||||
cmd_args << "#{identifier}:#{p}" if identifier |
||||
scm_cmd(*cmd_args) do |io| |
||||
io.each_line do |line| |
||||
e = line.chomp.to_s |
||||
if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/ |
||||
type = $1 |
||||
sha = $2 |
||||
size = $3 |
||||
name = $4 |
||||
if name.respond_to?(:force_encoding) |
||||
name.force_encoding(@path_encoding) |
||||
end |
||||
full_path = p.empty? ? name : "#{p}/#{name}" |
||||
n = scm_encode('UTF-8', @path_encoding, name) |
||||
full_p = scm_encode('UTF-8', @path_encoding, full_path) |
||||
entries << Entry.new(name: n, |
||||
path: full_p, |
||||
kind: (type == 'tree') ? 'dir' : 'file', |
||||
size: (type == 'tree') ? nil : size, |
||||
lastrev: @flag_report_last_commit ? lastrev(full_path, identifier) : Revision.new |
||||
) unless entries.detect { |entry| entry.name == name } |
||||
end |
||||
end |
||||
end |
||||
entries.sort_by_name |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def lastrev(path, rev) |
||||
return nil if path.nil? |
||||
cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1| |
||||
cmd_args << rev if rev |
||||
cmd_args << '--' << path unless path.empty? |
||||
lines = [] |
||||
scm_cmd(*cmd_args) { |io| lines = io.readlines } |
||||
begin |
||||
id = lines[0].split[1] |
||||
author = lines[1].match('Author:\s+(.*)$')[1] |
||||
time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1]) |
||||
|
||||
Revision.new( |
||||
identifier: id, |
||||
scmid: id, |
||||
author: author, |
||||
time: time, |
||||
message: nil, |
||||
paths: nil |
||||
) |
||||
rescue NoMethodError => e |
||||
logger.error("The revision '#{path}' has a wrong format") |
||||
return nil |
||||
end |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def revisions(path, identifier_from, identifier_to, options = {}) |
||||
revisions = Revisions.new |
||||
cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller| |
||||
cmd_args << '--reverse' if options[:reverse] |
||||
cmd_args << '--all' if options[:all] |
||||
cmd_args << '-n' << "#{options[:limit].to_i}" if options[:limit] |
||||
from_to = '' |
||||
from_to << "#{identifier_from}.." if identifier_from |
||||
from_to << "#{identifier_to}" if identifier_to |
||||
cmd_args << from_to if !from_to.empty? |
||||
cmd_args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since] |
||||
cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty? |
||||
|
||||
scm_cmd *cmd_args do |io| |
||||
files = [] |
||||
changeset = {} |
||||
parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files |
||||
|
||||
io.each_line do |line| |
||||
if line =~ /^commit ([0-9a-f]{40})$/ |
||||
key = 'commit' |
||||
value = $1 |
||||
if parsing_descr == 1 || parsing_descr == 2 |
||||
parsing_descr = 0 |
||||
revision = Revision.new( |
||||
identifier: changeset[:commit], |
||||
scmid: changeset[:commit], |
||||
author: changeset[:author], |
||||
time: Time.parse(changeset[:date]), |
||||
message: changeset[:description], |
||||
paths: files |
||||
) |
||||
if block_given? |
||||
yield revision |
||||
else |
||||
revisions << revision |
||||
end |
||||
changeset = {} |
||||
files = [] |
||||
end |
||||
changeset[:commit] = $1 |
||||
elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/ |
||||
key = $1 |
||||
value = $2 |
||||
if key == 'Author' |
||||
changeset[:author] = value |
||||
elsif key == 'CommitDate' |
||||
changeset[:date] = value |
||||
end |
||||
elsif (parsing_descr == 0) && line.chomp.to_s == '' |
||||
parsing_descr = 1 |
||||
changeset[:description] = '' |
||||
elsif (parsing_descr == 1 || parsing_descr == 2) \ |
||||
&& line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/ |
||||
parsing_descr = 2 |
||||
fileaction = $1 |
||||
filepath = $2 |
||||
p = scm_encode('UTF-8', @path_encoding, filepath) |
||||
files << { action: fileaction, path: p } |
||||
elsif (parsing_descr == 1 || parsing_descr == 2) \ |
||||
&& line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/ |
||||
parsing_descr = 2 |
||||
fileaction = $1 |
||||
filepath = $3 |
||||
p = scm_encode('UTF-8', @path_encoding, filepath) |
||||
files << { action: fileaction, path: p } |
||||
elsif (parsing_descr == 1) && line.chomp.to_s == '' |
||||
parsing_descr = 2 |
||||
elsif (parsing_descr == 1) |
||||
changeset[:description] << line[4..-1] |
||||
end |
||||
end |
||||
|
||||
if changeset[:commit] |
||||
revision = Revision.new( |
||||
identifier: changeset[:commit], |
||||
scmid: changeset[:commit], |
||||
author: changeset[:author], |
||||
time: Time.parse(changeset[:date]), |
||||
message: changeset[:description], |
||||
paths: files |
||||
) |
||||
|
||||
if block_given? |
||||
yield revision |
||||
else |
||||
revisions << revision |
||||
end |
||||
end |
||||
end |
||||
revisions |
||||
rescue ScmCommandAborted |
||||
revisions |
||||
end |
||||
|
||||
def diff(path, identifier_from, identifier_to = nil) |
||||
path ||= '' |
||||
cmd_args = [] |
||||
if identifier_to |
||||
cmd_args << 'diff' << '--no-color' << identifier_to << identifier_from |
||||
else |
||||
cmd_args << 'show' << '--no-color' << identifier_from |
||||
end |
||||
cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty? |
||||
diff = [] |
||||
scm_cmd *cmd_args do |io| |
||||
io.each_line do |line| |
||||
diff << line |
||||
end |
||||
end |
||||
diff |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def annotate(path, identifier = nil) |
||||
identifier = 'HEAD' if identifier.blank? |
||||
cmd_args = %w|blame| |
||||
cmd_args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path) |
||||
blame = Annotate.new |
||||
content = nil |
||||
scm_cmd(*cmd_args) { |io| io.binmode; content = io.read } |
||||
# git annotates binary files |
||||
if content.respond_to?('is_binary_data?') && content.is_binary_data? # Ruby 1.8.x and <1.9.2 |
||||
return nil |
||||
elsif content.respond_to?(:force_encoding) && (content.dup.force_encoding('UTF-8') != content.dup.force_encoding('BINARY')) # Ruby 1.9.2 |
||||
# TODO: need to handle edge cases of non-binary content that isn't UTF-8 |
||||
return nil |
||||
end |
||||
identifier = '' |
||||
# git shows commit author on the first occurrence only |
||||
authors_by_commit = {} |
||||
content.split("\n").each do |line| |
||||
if line =~ /^([0-9a-f]{39,40})\s.*/ |
||||
identifier = $1 |
||||
elsif line =~ /^author (.+)/ |
||||
authors_by_commit[identifier] = $1.strip |
||||
elsif line =~ /^\t(.*)/ |
||||
blame.add_line($1, Revision.new( |
||||
identifier: identifier, |
||||
author: authors_by_commit[identifier])) |
||||
identifier = '' |
||||
author = '' |
||||
end |
||||
end |
||||
blame |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
def cat(path, identifier = nil) |
||||
if identifier.nil? |
||||
identifier = 'HEAD' |
||||
end |
||||
cmd_args = %w|show --no-color| |
||||
cmd_args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}" |
||||
cat = nil |
||||
scm_cmd(*cmd_args) do |io| |
||||
io.binmode |
||||
cat = io.read |
||||
end |
||||
cat |
||||
rescue ScmCommandAborted |
||||
nil |
||||
end |
||||
|
||||
class Revision < Redmine::Scm::Adapters::Revision |
||||
# Returns the readable identifier |
||||
def format_identifier |
||||
identifier[0, 8] |
||||
end |
||||
end |
||||
|
||||
def scm_cmd(*args, &block) |
||||
repo_path = root_url || url |
||||
full_args = [GIT_BIN, '--git-dir', repo_path] |
||||
if self.class.client_version_above?([1, 7, 2]) |
||||
full_args << '-c' << 'core.quotepath=false' |
||||
end |
||||
full_args += args |
||||
ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block) |
||||
if $? && $?.exitstatus != 0 |
||||
raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}" |
||||
end |
||||
ret |
||||
end |
||||
private :scm_cmd |
||||
end |
||||
end |
||||
end |
||||
end |
@ -1,293 +0,0 @@ |
||||
#-- encoding: UTF-8 |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# 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) any later version. |
||||
# |
||||
# 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, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See doc/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter' |
||||
require 'uri' |
||||
|
||||
module Redmine |
||||
module Scm |
||||
module Adapters |
||||
class SubversionAdapter < AbstractAdapter |
||||
# SVN executable name |
||||
SVN_BIN = OpenProject::Configuration['scm_subversion_command'] || 'svn' |
||||
|
||||
class << self |
||||
def client_command |
||||
@@bin ||= SVN_BIN |
||||
end |
||||
|
||||
def sq_bin |
||||
@@sq_bin ||= shell_quote(SVN_BIN) |
||||
end |
||||
|
||||
def client_version |
||||
@@client_version ||= (svn_binary_version || []) |
||||
end |
||||
|
||||
def client_available |
||||
!client_version.empty? |
||||
end |
||||
|
||||
def svn_binary_version |
||||
scm_version = scm_version_from_command_line.dup |
||||
if scm_version.respond_to?(:force_encoding) |
||||
scm_version.force_encoding('ASCII-8BIT') |
||||
end |
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) |
||||
m[2].scan(%r{\d+}).map(&:to_i) |
||||
end |
||||
end |
||||
|
||||
def scm_version_from_command_line |
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s |
||||
end |
||||
end |
||||
|
||||
# Get info about the svn repository |
||||
def info |
||||
cmd = "#{self.class.sq_bin} info --xml #{target}" |
||||
cmd << credentials_string |
||||
info = nil |
||||
shellout(cmd) do |io| |
||||
output = io.read |
||||
if output.respond_to?(:force_encoding) |
||||
output.force_encoding('UTF-8') |
||||
end |
||||
begin |
||||
doc = ActiveSupport::XmlMini.parse(output) |
||||
# root_url = doc.elements["info/entry/repository/root"].text |
||||
info = Info.new(root_url: doc['info']['entry']['repository']['root']['__content__'], |
||||
lastrev: Revision.new( |
||||
identifier: doc['info']['entry']['commit']['revision'], |
||||
time: Time.parse(doc['info']['entry']['commit']['date']['__content__']).localtime, |
||||
author: (doc['info']['entry']['commit']['author'] ? doc['info']['entry']['commit']['author']['__content__'] : '') |
||||
) |
||||
) |
||||
rescue |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
info |
||||
rescue CommandFailed |
||||
return nil |
||||
end |
||||
|
||||
# Returns an Entries collection |
||||
# or nil if the given path doesn't exist in the repository |
||||
def entries(path = nil, identifier = nil) |
||||
path ||= '' |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
entries = Entries.new |
||||
cmd = "#{self.class.sq_bin} list --xml #{target(path)}@#{identifier}" |
||||
cmd << credentials_string |
||||
shellout(cmd) do |io| |
||||
output = io.read |
||||
if output.respond_to?(:force_encoding) |
||||
output.force_encoding('UTF-8') |
||||
end |
||||
begin |
||||
doc = ActiveSupport::XmlMini.parse(output) |
||||
each_xml_element(doc['lists']['list'], 'entry') do |entry| |
||||
commit = entry['commit'] |
||||
commit_date = commit['date'] |
||||
# Skip directory if there is no commit date (usually that |
||||
# means that we don't have read access to it) |
||||
next if entry['kind'] == 'dir' && commit_date.nil? |
||||
name = entry['name']['__content__'] |
||||
entries << Entry.new(name: URI.unescape(name), |
||||
path: ((path.empty? ? '' : "#{path}/") + name), |
||||
kind: entry['kind'], |
||||
size: ((s = entry['size']) ? s['__content__'].to_i : nil), |
||||
lastrev: Revision.new( |
||||
identifier: commit['revision'], |
||||
time: Time.parse(commit_date['__content__'].to_s).localtime, |
||||
author: ((a = commit['author']) ? a['__content__'] : nil) |
||||
) |
||||
) |
||||
end |
||||
rescue => e |
||||
logger.error("Error parsing svn output: #{e.message}") |
||||
logger.error("Output was:\n #{output}") |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug? |
||||
entries.sort_by_name |
||||
end |
||||
|
||||
def properties(path, identifier = nil) |
||||
# proplist xml output supported in svn 1.5.0 and higher |
||||
return nil unless self.class.client_version_above?([1, 5, 0]) |
||||
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = "#{self.class.sq_bin} proplist --verbose --xml #{target(path)}@#{identifier}" |
||||
cmd << credentials_string |
||||
properties = {} |
||||
shellout(cmd) do |io| |
||||
output = io.read |
||||
if output.respond_to?(:force_encoding) |
||||
output.force_encoding('UTF-8') |
||||
end |
||||
begin |
||||
doc = ActiveSupport::XmlMini.parse(output) |
||||
each_xml_element(doc['properties']['target'], 'property') do |property| |
||||
properties[property['name']] = property['__content__'].to_s |
||||
end |
||||
rescue |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
properties |
||||
end |
||||
|
||||
def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {}) |
||||
path ||= '' |
||||
identifier_from = (identifier_from && identifier_from.to_i > 0) ? identifier_from.to_i : 'HEAD' |
||||
identifier_to = (identifier_to && identifier_to.to_i > 0) ? identifier_to.to_i : 1 |
||||
revisions = Revisions.new |
||||
cmd = "#{self.class.sq_bin} log --xml -r #{identifier_from}:#{identifier_to}" |
||||
cmd << credentials_string |
||||
cmd << ' --verbose ' if options[:with_paths] |
||||
cmd << " --limit #{options[:limit].to_i}" if options[:limit] |
||||
cmd << ' ' + target(path) |
||||
shellout(cmd) do |io| |
||||
output = io.read |
||||
if output.respond_to?(:force_encoding) |
||||
output.force_encoding('UTF-8') |
||||
end |
||||
begin |
||||
doc = ActiveSupport::XmlMini.parse(output) |
||||
each_xml_element(doc['log'], 'logentry') do |logentry| |
||||
paths = [] |
||||
each_xml_element(logentry['paths'], 'path') do |path| |
||||
paths << { action: path['action'], |
||||
path: path['__content__'], |
||||
from_path: path['copyfrom-path'], |
||||
from_revision: path['copyfrom-rev'] |
||||
} |
||||
end if logentry['paths'] && logentry['paths']['path'] |
||||
paths.sort! { |x, y| x[:path] <=> y[:path] } |
||||
|
||||
revisions << Revision.new(identifier: logentry['revision'], |
||||
author: (logentry['author'] ? logentry['author']['__content__'] : ''), |
||||
time: Time.parse(logentry['date']['__content__'].to_s).localtime, |
||||
message: logentry['msg']['__content__'], |
||||
paths: paths |
||||
) |
||||
end |
||||
rescue |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
revisions |
||||
end |
||||
|
||||
def diff(path, identifier_from, identifier_to = nil, _type = 'inline') |
||||
path ||= '' |
||||
identifier_from = (identifier_from and identifier_from.to_i > 0) ? identifier_from.to_i : '' |
||||
|
||||
identifier_to = (identifier_to and identifier_to.to_i > 0) ? identifier_to.to_i : (identifier_from.to_i - 1) |
||||
|
||||
cmd = "#{self.class.sq_bin} diff -r " |
||||
cmd << "#{identifier_to}:" |
||||
cmd << "#{identifier_from}" |
||||
cmd << " #{target(path)}@#{identifier_from}" |
||||
cmd << credentials_string |
||||
diff = [] |
||||
shellout(cmd) do |io| |
||||
io.each_line do |line| |
||||
diff << line |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
diff |
||||
end |
||||
|
||||
def cat(path, identifier = nil) |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = "#{self.class.sq_bin} cat #{target(path)}@#{identifier}" |
||||
cmd << credentials_string |
||||
cat = nil |
||||
shellout(cmd) do |io| |
||||
io.binmode |
||||
cat = io.read |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
cat |
||||
end |
||||
|
||||
def annotate(path, identifier = nil) |
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' |
||||
cmd = "#{self.class.sq_bin} blame #{target(path)}@#{identifier}" |
||||
cmd << credentials_string |
||||
blame = Annotate.new |
||||
shellout(cmd) do |io| |
||||
io.each_line do |line| |
||||
next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$} |
||||
blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip)) |
||||
end |
||||
end |
||||
return nil if $? && $?.exitstatus != 0 |
||||
blame |
||||
end |
||||
|
||||
private |
||||
|
||||
def credentials_string |
||||
str = '' |
||||
str << " --username #{shell_quote(@login)}" unless @login.blank? |
||||
str << " --password #{shell_quote(@password)}" unless @login.blank? || @password.blank? |
||||
str << ' --no-auth-cache --non-interactive' |
||||
str |
||||
end |
||||
|
||||
# Helper that iterates over the child elements of a xml node |
||||
# MiniXml returns a hash when a single child is found or an array of hashes for multiple children |
||||
def each_xml_element(node, name) |
||||
if node && node[name] |
||||
if node[name].is_a?(Hash) |
||||
yield node[name] |
||||
else |
||||
node[name].each do |element| |
||||
yield element |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
def target(path = '') |
||||
base = path.match(/\A\//) ? root_url : url |
||||
uri = "#{base}/#{path}" |
||||
uri = URI.escape(URI.escape(uri), '[]') |
||||
shell_quote(uri.gsub(/[?<>\*]/, '')) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue