├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── controllers │ └── git_mirror_controller.rb ├── models │ └── repository │ │ └── git_mirror.rb └── views │ └── git_mirror │ └── _settings.html.erb ├── config ├── locales │ ├── en.yml │ └── ru.yml └── routes.rb ├── init.rb ├── lib └── redmine_git_mirror │ ├── git.rb │ ├── patches │ └── repositories_helper_patch.rb │ ├── settings.rb │ ├── ssh.rb │ └── url.rb └── repos └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | .editorconfig export-ignore 2 | .travis.yml export-ignore 3 | docs export-ignore 4 | test export-ignore 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | include: 10 | - redmine-version: '4.2' 11 | ruby-version: '2.7' 12 | 13 | # - redmine-version: '5.0' 14 | # ruby-version: '3.0' 15 | 16 | env: 17 | RAILS_ENV: test 18 | REDMINE_DIR: ${{ github.workspace }}/redmine 19 | 20 | steps: 21 | - uses: browser-actions/setup-chrome@v1 22 | with: { chrome-version: 'stable' } 23 | 24 | - uses: actions/checkout@v3 25 | 26 | - name: Set up Ruby ${{ matrix.ruby-version }} 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby-version }} 30 | 31 | - name: Checkout redmine ${{ matrix.redmine-version }} 32 | uses: actions/checkout@v3 33 | with: 34 | path: redmine 35 | repository: redmine/redmine 36 | ref: ${{ matrix.redmine-version }}-stable 37 | 38 | - name: Install deps 39 | run: | 40 | cd $REDMINE_DIR 41 | mkdir -p plugins 42 | ln -s ${{ github.workspace }} plugins/redmine_git_mirror 43 | ./../test/docker/redmine/scripts/configure.sh 44 | 45 | - name: Migrate DB 46 | run: test/docker/redmine/scripts/migrate-db.sh 47 | 48 | - name: Run tests 49 | run: CHROMIUM_BIN=$(which chrome) test/docker/redmine/run-tests.sh 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sergey Linnik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Redmine Git Mirror plugin [![CI](https://github.com/linniksa/redmine_git_mirror/actions/workflows/main.yml/badge.svg)](https://github.com/linniksa/redmine_git_mirror/actions/workflows/main.yml) 2 | ================== 3 | 4 | Adds ability to clone and fetch remote git repositories to redmine. 5 | 6 | ## Key Features 7 | * Easy install (just clone to redmine plugins folder) 8 | * Webhooks integration (gitlab and custom) 9 | * Works well with enabled autofetch changesets setting and in mix with other scm types 10 | * Automatic deletes unreachable commits 11 | 12 | # Install 13 | 14 | cd [redmine-root]/plugins 15 | git clone https://github.com/linniksa/redmine_git_mirror 16 | 17 | Restart redmine, and enable `Git Mirror` scm type at `redmine.site/settings?tab=repositories` 18 | 19 | ## Accessing private repositories 20 | 21 | At this moment only ssh access with redmine user ssh key is supported. 22 | 23 | # Fetching changes 24 | 25 | This plugin supports 2 ways of fetching changes, via cronjob or via hooks. 26 | You can use only one or both of them together. 27 | 28 | ## Cronjob 29 | 30 | Run ```./bin/rails runner "Repository::GitMirror.fetch"```, for example: 31 | 32 | 5,20,35,50 * * * * cd /usr/src/redmine && ./bin/rails runner "Repository::GitMirror.fetch" -e production >> log/cron_rake.log 2>&1 33 | 34 | ## Hooks 35 | 36 | Hooks is preferred way because you can immediately see changes of you repository. 37 | 38 | ### GitLab hooks 39 | 40 | You can setup per-project or system wide hook, for both variants use `redmine.site/sys/git_mirror/gitlab` as `URL` 41 | 42 | ###### For system wide setup 43 | 44 | Go to `gitlab.site/admin/hooks`, and select only `Repository update events` trigger. 45 | 46 | ###### For per-project setup 47 | 48 | Go to `gitlab.site/user/project/settings/integrations`, and select only `Push` and `Tags` events 49 | 50 | ### GitHub hooks 51 | 52 | You can setup per-project or group wide hook, for both variants 53 | use `redmine.site/sys/git_mirror/github` as `Payload URL` and `Just the push event` option. 54 | 55 | Don't worry about `Content type` both `application/json` and `application/x-www-form-urlencoded` are supported. 56 | -------------------------------------------------------------------------------- /app/controllers/git_mirror_controller.rb: -------------------------------------------------------------------------------- 1 | 2 | class GitMirrorController < ActionController::Base 3 | 4 | # abstract hook for repo update via remote url 5 | def fetch 6 | url = params[:url] 7 | begin 8 | RedmineGitMirror::URL.parse(url) 9 | rescue 10 | head 400 11 | return 12 | end 13 | found = fetch_by_urls([url]) 14 | 15 | head found ? 202 : 404 16 | end 17 | 18 | # process gitlab webhook request 19 | def gitlab 20 | event = params[:event_name] 21 | unless request.post? && event 22 | head 400 23 | return 24 | end 25 | 26 | unless %w[push repository_update].include?(event.to_s) 27 | head 200 28 | return 29 | end 30 | 31 | project = params[:project] 32 | unless project 33 | head 422 34 | return 35 | end 36 | 37 | urls = [] 38 | 39 | [:git_ssh_url, :git_http_url].each do |p| 40 | url = project[p].to_s 41 | 42 | urls.push(url) if url.length > 0 43 | end 44 | 45 | if urls.length <= 0 46 | head 422 47 | return 48 | end 49 | 50 | found = fetch_by_urls(urls) 51 | head found ? 202 : 404 52 | end 53 | 54 | # process github webhook request 55 | def github 56 | event = request.headers["x-github-event"] 57 | unless request.post? && event 58 | head 400 59 | return 60 | end 61 | 62 | unless %w[push].include?(event.to_s) 63 | head 200 64 | return 65 | end 66 | 67 | payload = params[:payload] 68 | 69 | if payload && request.content_type != 'application/json' 70 | payload = JSON.parse(payload, :symbolize_names => true) 71 | else 72 | payload = params 73 | end 74 | 75 | unless payload 76 | head 422 77 | return 78 | end 79 | 80 | repository = payload[:repository] 81 | unless repository 82 | head 422 83 | return 84 | end 85 | 86 | urls = [] 87 | 88 | [:ssh_url, :clone_url, :git_url].each do |p| 89 | url = repository[p].to_s 90 | 91 | urls.push(url) if url.length > 0 92 | end 93 | 94 | if urls.length <= 0 95 | head 422 96 | return 97 | end 98 | 99 | found = fetch_by_urls(urls) 100 | head found ? 202 : 404 101 | end 102 | 103 | private def fetch_by_urls(urls) 104 | urls_to_search = [] 105 | 106 | urls.each do |url| 107 | begin 108 | urls_to_search.concat RedmineGitMirror::URL.parse(url).vary 109 | rescue Exception => _ 110 | urls_to_search.push(url) 111 | end 112 | end 113 | 114 | found = false 115 | Repository::GitMirror.active.where(url: urls_to_search).find_each do |repository| 116 | found = true unless found 117 | repository.fetch() 118 | end 119 | 120 | found 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /app/models/repository/git_mirror.rb: -------------------------------------------------------------------------------- 1 | 2 | class Repository::GitMirror < Repository::Git 3 | 4 | before_validation :validate_and_normalize_url, on: [:create, :update] 5 | before_validation :set_defaults, on: :create 6 | after_validation :init_repo, on: :create 7 | after_commit :fetch, on: :create 8 | 9 | after_validation :update_remote_url, on: :update 10 | 11 | before_destroy :remove_repo 12 | 13 | scope :active, lambda { 14 | joins(:project).merge(Project.active) 15 | } 16 | 17 | safe_attributes 'url', :if => lambda { |repository, user| 18 | repository.new_record? || RedmineGitMirror::Settings.url_change_allowed? 19 | } 20 | 21 | private def update_remote_url 22 | return unless self.errors.empty? 23 | return unless self.url_changed? 24 | 25 | r, err = RedmineGitMirror::Git.get_remote_url(root_url) 26 | if err 27 | errors.add :url, err 28 | return 29 | end 30 | 31 | return if r == url 32 | 33 | err = RedmineGitMirror::Git.set_remote_url(root_url, url) 34 | errors.add :url, err if err 35 | end 36 | 37 | private def remove_repo 38 | root_url = self.root_url.to_s 39 | 40 | return if root_url.empty? 41 | return if root_url == '/' 42 | return if root_url.to_s.length <= 15 43 | 44 | # check git dirs and files 45 | return unless File.exist? root_url + '/config' 46 | return unless Dir.exist? root_url + '/objects' 47 | 48 | FileUtils.rm_rf root_url 49 | end 50 | 51 | private def validate_and_normalize_url 52 | return unless self.new_record? || self.url_changed? 53 | 54 | url = self.url.to_s.strip 55 | 56 | return if url.to_s.empty? 57 | 58 | begin 59 | parsed_url = RedmineGitMirror::URL.parse(url) 60 | rescue Exception => msg 61 | errors.add :url, msg.to_s 62 | return 63 | end 64 | 65 | unless parsed_url.remote? 66 | errors.add :url, 'should be remote url' 67 | return 68 | end 69 | 70 | unless parsed_url.scheme?(*RedmineGitMirror::Settings.allowed_schemes) 71 | s = RedmineGitMirror::Settings.allowed_schemes 72 | err = s.empty?? 'no allowed schemes' : "scheme not allowed, only #{s.join', '} is allowed" 73 | errors.add :url, err 74 | return 75 | end 76 | 77 | if parsed_url.has_credential? 78 | errors.add :url, 'cannot use credentials' 79 | return 80 | end 81 | 82 | self.url = parsed_url.normalize 83 | 84 | err = RedmineGitMirror::Git.check_remote_url(self.url) 85 | if err 86 | errors.add :url, err 87 | return 88 | end 89 | 90 | if RedmineGitMirror::Settings.prevent_multiple_clones? 91 | urls = RedmineGitMirror::URL.parse(url).vary( 92 | :all => RedmineGitMirror::Settings.search_clones_in_all_schemes? 93 | ) 94 | 95 | if Repository::GitMirror.where(url: urls).where.not(id: self.id).exists? 96 | errors.add :url, 'is already mirrored in redmine' 97 | return 98 | end 99 | end 100 | end 101 | 102 | private def set_defaults 103 | return unless self.errors.empty? && !url.to_s.empty? 104 | 105 | parsed_url = RedmineGitMirror::URL.parse(url) 106 | if identifier.empty? 107 | identifier = File.basename(parsed_url.path, ".*") 108 | self.identifier = identifier if /^[a-z][a-z0-9_-]*$/.match(identifier) 109 | end 110 | 111 | self.root_url = RedmineGitMirror::Settings.path + '/' + 112 | Time.now.strftime("%Y%m%d%H%M%S%L") + 113 | "_" + 114 | (parsed_url.host + parsed_url.path.gsub(/\.git$/, '')).gsub(/[\\\/]+/, '_').gsub(/[^A-Za-z._-]/, '')[0..64] 115 | end 116 | 117 | private def init_repo 118 | return unless self.errors.empty? 119 | 120 | err = RedmineGitMirror::Git.init(root_url, url) 121 | errors.add :url, err if err 122 | end 123 | 124 | def fetch_changesets(fetch = false) 125 | return unless fetch 126 | super() 127 | end 128 | 129 | def fetch 130 | return if @fetched 131 | @fetched = true 132 | 133 | puts "Fetching repo #{url} to #{root_url}" 134 | 135 | err = RedmineGitMirror::Git.fetch(root_url, url) 136 | Rails.logger.warn 'Err with fetching: ' + err if err 137 | 138 | remove_unreachable_commits 139 | fetch_changesets(true) 140 | end 141 | 142 | private def remove_unreachable_commits 143 | commits, e = RedmineGitMirror::Git.unreachable_commits(root_url) 144 | if e 145 | Rails.logger.warn 'Err when fetching unreachable commits: ' + e 146 | return 147 | end 148 | 149 | return if commits.empty? 150 | 151 | # remove commits from heads extra info 152 | h = extra_info ? extra_info["heads"] : nil 153 | if h 154 | h1 = h.dup 155 | commits.each { |c| h1.delete(c) } 156 | 157 | if h1.length != h.length 158 | n = {} 159 | n["heads"] = h1 160 | 161 | merge_extra_info(n) 162 | save 163 | end 164 | end 165 | 166 | Changeset.where(repository: self, revision: commits).destroy_all 167 | 168 | RedmineGitMirror::Git.prune(root_url) if commits.length >= 10 169 | end 170 | 171 | class << self 172 | def scm_name 173 | 'Git Mirror' 174 | end 175 | 176 | def human_attribute_name(attribute_key_name, *args) 177 | attr_name = attribute_key_name.to_s 178 | 179 | Repository.human_attribute_name(attr_name, *args) 180 | end 181 | 182 | # Fetches new changes for all git mirror repositories in active projects 183 | # Can be called periodically by an external script 184 | # eg. bin/rails runner "Repository::GitMirror.fetch" 185 | def fetch 186 | Repository::GitMirror.active.find_each(&:fetch) 187 | end 188 | end 189 | 190 | end 191 | -------------------------------------------------------------------------------- /app/views/git_mirror/_settings.html.erb: -------------------------------------------------------------------------------- 1 | 4 | <%= ''.html_safe %> 5 | 8 | 9 | 10 |
11 | General 12 | 13 |

14 | 17 | <%= hidden_field_tag "settings[url_change_allowed]", 0 %> 18 | <%= check_box_tag "settings[url_change_allowed]", 1, RedmineGitMirror::Settings.url_change_allowed?, 19 | :id => 'url_change_allowed' 20 | %> 21 |

22 | 23 |

24 | 27 | <%= hidden_field_tag "settings[prevent_multiple_clones]", 0 %> 28 | <%= check_box_tag "settings[prevent_multiple_clones]", 1, RedmineGitMirror::Settings.prevent_multiple_clones?, 29 | :id => 'prevent_multiple_clones', 30 | :data => {:shows => '.prevent_multiple_clones_shows'} 31 | %> 32 |

33 | 34 |

35 | 38 | <%= hidden_field_tag "settings[search_clones_in_all_schemes]", 0 %> 39 | <%= check_box_tag "settings[search_clones_in_all_schemes]", 1, RedmineGitMirror::Settings.search_clones_in_all_schemes?, 40 | :id => 'search_clones_in_all_schemes' 41 | %> 42 |

43 | 44 |
45 | 46 |
47 | Permitted url schemes 48 | 49 | <% { 50 | 'http' => 'http://site/project.git', 51 | 'https' => 'https://site/project.git', 52 | 'ssh' => 'ssh://site/project.git', 53 | 'scp' => 'git@site:project.git', 54 | }.each do | key, url |%> 55 | 64 | <% end %> 65 | 66 |
67 |

<%= check_all_links 'git_mirror_schemas' %>

68 | 69 | 70 | 71 | <%= '
'.html_safe %> 72 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | text_git_mirror_url_note: URL of remote repository. 3 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | text_git_mirror_url_note: URL удаленного репозитория. 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | 2 | match 'sys/git_mirror/fetch', to: 'git_mirror#fetch', via: [:get, :post] 3 | match 'sys/git_mirror/gitlab', to: 'git_mirror#gitlab', via: [:post] 4 | match 'sys/git_mirror/github', to: 'git_mirror#github', via: [:post] 5 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | require_dependency 'redmine_git_mirror/git' 3 | require_dependency 'redmine_git_mirror/ssh' 4 | require_dependency 'redmine_git_mirror/url' 5 | require_dependency 'redmine_git_mirror/settings' 6 | 7 | Redmine::Scm::Base.add 'GitMirror' 8 | 9 | Redmine::Plugin.register :redmine_git_mirror do 10 | name 'Git Mirror' 11 | author 'Sergey Linnik' 12 | description 'Add ability to create readonly mirror of remote git repository' 13 | version '0.8.0' 14 | url 'https://github.com/linniksa/redmine_git_mirror' 15 | author_url 'https://github.com/linniksa' 16 | 17 | requires_redmine :version_or_higher => '3.3.0' 18 | 19 | settings :default => RedmineGitMirror::Settings::DEFAULT, :partial => 'git_mirror/settings' 20 | 21 | end 22 | 23 | redmine_git_mirror_patches = proc do 24 | require 'repositories_helper' 25 | require 'redmine_git_mirror/patches/repositories_helper_patch' 26 | 27 | def include(klass, patch) 28 | klass.send(:include, patch) unless klass.included_modules.include?(patch) 29 | end 30 | 31 | include(RepositoriesHelper, RedmineGitMirror::Patches::RepositoriesHelperPatch) 32 | end 33 | 34 | # Patches to the Redmine core. 35 | require 'dispatcher' unless Rails::VERSION::MAJOR >= 3 36 | 37 | if Rails::VERSION::MAJOR >= 5 38 | ActiveSupport::Reloader.to_prepare &redmine_git_mirror_patches 39 | elsif Rails::VERSION::MAJOR >= 3 40 | ActionDispatch::Callbacks.to_prepare &redmine_git_mirror_patches 41 | else 42 | Dispatcher.to_prepare &redmine_git_mirror_patches 43 | end 44 | -------------------------------------------------------------------------------- /lib/redmine_git_mirror/git.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | 3 | module RedmineGitMirror 4 | class Git 5 | class << self 6 | GIT_BIN = Redmine::Configuration['scm_git_command'] || 'git' 7 | 8 | def check_remote_url(url) 9 | url = RedmineGitMirror::URL.parse(url) 10 | RedmineGitMirror::SSH.ensure_host_known(url.host) if url.uses_ssh? 11 | 12 | _, e = git 'ls-remote', '-h', url.to_s, 'master' 13 | e 14 | end 15 | 16 | def unreachable_commits(path) 17 | o, e = git "--git-dir", path, "fsck", "--unreachable", "--no-reflogs", "--no-progress" 18 | return nil, e if e 19 | 20 | prefix = 'unreachable commit ' 21 | 22 | commits = o.lines.lazy 23 | .select { | line | line.start_with?(prefix) } 24 | .map { |line| line[prefix.length..-1].strip } 25 | .to_a 26 | 27 | return commits, nil 28 | end 29 | 30 | def prune(path) 31 | _, e = git "--git-dir", path, "prune" 32 | e 33 | end 34 | 35 | def init(clone_path, url) 36 | url = RedmineGitMirror::URL.parse(url) 37 | RedmineGitMirror::SSH.ensure_host_known(url.host) if url.uses_ssh? 38 | 39 | if Dir.exists? clone_path 40 | o, e = get_remote_url(clone_path) 41 | return e if e 42 | 43 | return "#{clone_path} remote url differs" unless o == url.to_s 44 | else 45 | _, e = git "init", "--bare", clone_path 46 | return e if e 47 | 48 | _, e = git "--git-dir", clone_path, "remote", "add", "origin", url.to_s 49 | return e if e 50 | end 51 | 52 | set_fetch_refs(clone_path, [ 53 | '+refs/heads/*:refs/heads/*', 54 | '+refs/tags/*:refs/tags/*', 55 | # uncomment next line if you want to show (gitlab) merge requests as braches in redmine 56 | # '+refs/merge-requests/*/head:refs/heads/MR-*', 57 | ]) 58 | end 59 | 60 | def fetch(clone_path, url) 61 | e = RedmineGitMirror::Git.init(clone_path, url) 62 | return e if e 63 | 64 | _, e = git "--git-dir", clone_path, "fetch", "--prune", "--all" 65 | e 66 | end 67 | 68 | def get_remote_url(clone_path) 69 | o, e = git "--git-dir", clone_path, "config", "--get", "remote.origin.url" 70 | 71 | return o.to_s.strip, e 72 | end 73 | 74 | def set_remote_url(clone_path, url) 75 | _, e = git "--git-dir", clone_path, "remote", "set-url", "origin", url 76 | e 77 | end 78 | 79 | private def set_fetch_refs(clone_path, configs) 80 | o, e = git "--git-dir", clone_path, "config", "--get-all", "remote.origin.fetch" 81 | return e if e 82 | 83 | # special ref that removes all refs outside specified 84 | expected = ["+__-=_=-__/*:refs/*"] + configs 85 | 86 | if o && o.lines 87 | actual = o.lines.map(&:strip) 88 | return if expected.eql?(actual) 89 | end 90 | 91 | # need change 92 | _, e = git "--git-dir", clone_path, "config", "--unset-all", "remote.origin.fetch" 93 | return e if e 94 | 95 | expected.each do |v| 96 | _, e = git "--git-dir", clone_path, "config", "--add", "remote.origin.fetch", v 97 | return e if e 98 | end 99 | 100 | nil 101 | end 102 | 103 | private def git(*cmd) 104 | s, e, status = Open3.capture3({ 105 | 'GIT_SSH_COMMAND' => RedmineGitMirror::SSH.command.to_s 106 | }, GIT_BIN, *cmd) 107 | s.to_s.strip! 108 | 109 | return s, nil if status.success? 110 | 111 | e = guess_error_message(e).to_s.truncate(100) || "git exit with status #{status}" 112 | 113 | return s, e 114 | end 115 | 116 | private def guess_error_message(msg) 117 | msg = msg.to_s.strip 118 | 119 | return msg if msg.empty? 120 | 121 | msg.each_line do |line| 122 | line.strip! 123 | return line unless line.start_with?('Warning') 124 | end 125 | 126 | msg.lines.first.strip 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/redmine_git_mirror/patches/repositories_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineGitMirror 2 | module Patches 3 | unloadable 4 | module RepositoriesHelperPatch 5 | 6 | def git_mirror_field_tags(form, repository) 7 | content_tag('p', form.text_field( 8 | :url, 9 | :size => 60, 10 | :required => true, 11 | :disabled => !repository.safe_attribute?('url'), 12 | ) + 13 | content_tag('em', l(:text_git_mirror_url_note), :class => 'info') 14 | ) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/redmine_git_mirror/settings.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'singleton' 3 | 4 | module RedmineGitMirror 5 | module Settings 6 | DEFAULT = { 7 | :schemes => %w[http https scp], 8 | :url_change_allowed => false, 9 | :prevent_multiple_clones => true, 10 | :search_clones_in_all_schemes => true, 11 | }.freeze 12 | 13 | class << self 14 | def path 15 | File.expand_path(File.dirname(__FILE__) + '/../../repos/') 16 | end 17 | 18 | def allowed_schemes 19 | self[:schemes] || [] 20 | end 21 | 22 | def url_change_allowed? 23 | s = self[:url_change_allowed] || false 24 | 25 | s == true || s.to_s == '1' 26 | end 27 | 28 | def prevent_multiple_clones? 29 | s = self[:prevent_multiple_clones] || false 30 | 31 | s == true || s.to_s == '1' 32 | end 33 | 34 | def search_clones_in_all_schemes? 35 | s = self[:search_clones_in_all_schemes] || false 36 | 37 | s == true || s.to_s == '1' 38 | end 39 | 40 | private def [](key) 41 | key = key.intern if key.is_a?(String) 42 | settings = Setting[:plugin_redmine_git_mirror] || {} 43 | 44 | return settings[key] if settings.key?(key) 45 | return settings[key.to_s] if settings.key?(key.to_s) 46 | 47 | DEFAULT[key] 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/redmine_git_mirror/ssh.rb: -------------------------------------------------------------------------------- 1 | 2 | module RedmineGitMirror 3 | module SSH 4 | class << self 5 | def ensure_host_known(host) 6 | return unless host 7 | 8 | known_hosts = known_hosts_file 9 | 10 | o, status = Open3.capture2("ssh-keygen", "-F", host, "-f", known_hosts) 11 | if status.success? 12 | return if o.match /found/ 13 | else 14 | # ssh-keygen fail if known_hosts file is not exists just log and continue 15 | puts "ssh-keygen exited with non-zero status: #{status}" 16 | end 17 | 18 | FileUtils.mkdir_p File.dirname(known_hosts), :mode => 0700 19 | 20 | o, status = Open3.capture2("ssh-keyscan", host) 21 | unless status.success? 22 | puts "ssh-keyscan exited with non-zero status: #{status}" 23 | return 24 | end 25 | 26 | puts "Adding #{host} to #{known_hosts}" 27 | File.open(known_hosts, 'a', 0600) do |file| 28 | file.puts o 29 | end 30 | end 31 | 32 | def command 33 | 'ssh -o UserKnownHostsFile=' + known_hosts_file 34 | end 35 | 36 | private def known_hosts_file 37 | Dir.home + "/.ssh/known_hosts" 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/redmine_git_mirror/url.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'uri' 3 | 4 | module URI 5 | class GIT < Generic 6 | DEFAULT_PORT = 9418 7 | end 8 | @@schemes['GIT'] = GIT 9 | end 10 | 11 | module RedmineGitMirror 12 | class URL 13 | attr_reader :scheme, :user, :password, :host, :port, :path 14 | 15 | private def initialize(url) 16 | url = url.to_s 17 | raise 'Empty url' if url.empty? 18 | 19 | begin 20 | url = URI.parse(url) 21 | 22 | @scheme = url.scheme 23 | @user = url.user 24 | @password = url.password 25 | @host = url.host unless url.host.to_s.empty? 26 | @port = url.port 27 | @default_port = url.default_port 28 | @path = url.path 29 | 30 | return 31 | rescue 32 | end 33 | 34 | host, path = url.to_s.split(':', 2) 35 | if host.length == 1 && path[0] == '\\' 36 | #local windows path 37 | @path = url 38 | return 39 | end 40 | 41 | if host.length > 0 && path.length > 0 42 | return if parse_scp_like_url(host, path) 43 | end 44 | 45 | raise 'Unknown git remote url' 46 | end 47 | 48 | private def parse_scp_like_url(host, path) 49 | return if !path || path.include?(':') || path[0] == '/' 50 | 51 | if host.include? '@' 52 | user, host = host.split('@', 2) 53 | 54 | return if user.length <= 0 55 | return if host.include?('@') 56 | 57 | @user = user 58 | @host = host 59 | else 60 | @host = host 61 | end 62 | 63 | @path = '/' + path 64 | end 65 | 66 | def remote? 67 | !self.local? 68 | end 69 | 70 | def local? 71 | (@scheme.nil? && !scp_like?) || @scheme == "file" 72 | end 73 | 74 | def scp_like? 75 | @scheme.nil? && !@host.nil? 76 | end 77 | 78 | def uses_ssh? 79 | @scheme == 'ssh' || self.scp_like? 80 | end 81 | 82 | def scheme?(*schemes) 83 | schemes.include?(self.scp_like? ? 'scp' : self.scheme) 84 | end 85 | 86 | def has_credential? 87 | return false if uses_ssh? && password.nil? 88 | 89 | !password.nil? || !user.nil? 90 | end 91 | 92 | def normalize 93 | o = self.dup 94 | 95 | path = o.path.gsub(/\/{2,}/, '/') 96 | o.instance_variable_set(:@path, path) 97 | 98 | o.to_s 99 | end 100 | 101 | def vary(all: false) 102 | schemes = %w[http https ssh scp] 103 | http_schemes = %w[http https] 104 | ssh_schemes = %w[ssh scp] 105 | 106 | current_scheme = self.scp_like?? 'scp' : self.scheme 107 | 108 | unless all 109 | if http_schemes.include?(current_scheme) 110 | schemes = http_schemes 111 | elsif ssh_schemes.include?(current_scheme) 112 | schemes = ssh_schemes 113 | else 114 | schemes = [current_scheme] 115 | end 116 | end 117 | 118 | rez = [] 119 | schemes.each do |scheme| 120 | s = to_scheme(scheme) 121 | rez.concat(s.vary_suffix('.git')) 122 | end 123 | 124 | rez 125 | end 126 | 127 | def vary_suffix(suffix) 128 | return self.dup unless path 129 | 130 | [ 131 | to_suffix(suffix, present: false).to_s, 132 | to_suffix(suffix, present: true).to_s 133 | ] 134 | end 135 | 136 | private def to_scheme(scheme) 137 | n = self.dup 138 | if scheme == 'scp' 139 | return n if self.scp_like? 140 | 141 | n.instance_variable_set(:@user, 'git') unless n.user 142 | n.instance_variable_set(:@scheme, nil) 143 | 144 | return n 145 | elsif scheme == 'ssh' 146 | n.instance_variable_set(:@user, 'git') unless n.user 147 | end 148 | 149 | n.instance_variable_set(:@scheme, scheme) 150 | if self.scp_like? 151 | n.instance_variable_set(:@user, nil) unless %w[ssh scp].include? scheme 152 | end 153 | 154 | n 155 | end 156 | 157 | private def to_suffix(suffix, present: ) 158 | n = self.dup 159 | return n unless path 160 | 161 | if present && !path.end_with?(suffix) 162 | n.instance_variable_set(:@path, path + suffix) 163 | elsif !present 164 | n.instance_variable_set(:@path, path.chomp(suffix)) 165 | end 166 | 167 | n 168 | end 169 | 170 | def to_h 171 | rez = {} 172 | rez[:scheme] = @scheme if @scheme 173 | rez[:user] = @user if @user 174 | rez[:password] = @password if @password 175 | rez[:host] = @host if @host 176 | rez[:port] = @port if @port 177 | rez[:path] = @path if @path 178 | rez 179 | end 180 | 181 | def to_s 182 | s = StringIO.new 183 | 184 | if @scheme 185 | s << @scheme 186 | s << '://' 187 | 188 | if @user 189 | s << @user 190 | if @password 191 | s << ':' 192 | s << @password 193 | end 194 | s << '@' 195 | end 196 | 197 | s << @host 198 | if @port and @port != @default_port 199 | s << ':' 200 | s << @port 201 | end 202 | s << path 203 | elsif @host 204 | if @user 205 | s << @user 206 | s << '@' 207 | end 208 | 209 | s << host 210 | s << ':' 211 | s << path[1..-1] 212 | else 213 | return path 214 | end 215 | 216 | s.string 217 | end 218 | 219 | class << self 220 | def parse(url) 221 | return url if url.is_a? self 222 | 223 | self.new(url.to_s) 224 | end 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /repos/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | --------------------------------------------------------------------------------