1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: AGPL-3.0
6 extend CurrentApiClient
8 class GitError < RequestError
14 def self.git_check_ref_format(e)
15 if !e or e.empty? or e[0] == '-' or e[0] == '$'
16 # definitely not valid
19 `git check-ref-format --allow-onelevel #{e.shellescape}`
24 # Return an array of commits (each a 40-char sha1) satisfying the
27 # Return [] if the revisions given in minimum/maximum are invalid or
28 # don't exist in the given repository.
30 # Raise ArgumentError if the given repository is invalid, does not
31 # exist, or cannot be read for any reason. (Any transient error that
32 # prevents commit ranges from resolving must raise rather than
33 # returning an empty array.)
35 # repository can be the name of a locally hosted repository or a git
36 # URL (see git-fetch(1)). Currently http, https, and git schemes are
38 def self.find_commit_range repository, minimum, maximum, exclude
39 if minimum and minimum.empty?
43 if minimum and !git_check_ref_format(minimum)
44 Rails.logger.warn "find_commit_range called with invalid minimum revision: '#{minimum}'"
48 if maximum and !git_check_ref_format(maximum)
49 Rails.logger.warn "find_commit_range called with invalid maximum revision: '#{maximum}'"
57 gitdir, is_remote = git_dir_for repository
58 fetch_remote_repository gitdir, repository if is_remote
59 ENV['GIT_DIR'] = gitdir
63 # Get the commit hash for the upper bound
65 git_max_hash_cmd = "git rev-list --max-count=1 #{maximum.shellescape} --"
66 IO.foreach("|#{git_max_hash_cmd}") do |line|
70 # If not found, nothing else to do
72 Rails.logger.warn "no refs found looking for max_hash: `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` returned no output"
76 # If string is invalid, nothing else to do
77 if !git_check_ref_format(max_hash)
78 Rails.logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` was invalid for max_hash: #{max_hash}"
82 resolved_exclude = nil
86 if git_check_ref_format(e)
87 IO.foreach("|git rev-list --max-count=1 #{e.shellescape} --") do |line|
88 resolved_exclude.push(line.strip)
91 Rails.logger.warn "find_commit_range called with invalid exclude invalid characters: '#{exclude}'"
98 # Get the commit hash for the lower bound
100 git_min_hash_cmd = "git rev-list --max-count=1 #{minimum.shellescape} --"
101 IO.foreach("|#{git_min_hash_cmd}") do |line|
102 min_hash = line.strip
105 # If not found, nothing else to do
107 Rails.logger.warn "no refs found looking for min_hash: `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` returned no output"
111 # If string is invalid, nothing else to do
112 if !git_check_ref_format(min_hash)
113 Rails.logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` was invalid for min_hash: #{min_hash}"
117 # Now find all commits between them
118 IO.foreach("|git rev-list #{min_hash.shellescape}..#{max_hash.shellescape} --") do |line|
120 commits.push(hash) if !resolved_exclude or !resolved_exclude.include? hash
123 commits.push(min_hash) if !resolved_exclude or !resolved_exclude.include? min_hash
125 commits.push(max_hash) if !resolved_exclude or !resolved_exclude.include? max_hash
131 # Given a repository (url, or name of hosted repo) and commit sha1,
132 # copy the commit into the internal git repo (if necessary), and tag
133 # it with the given tag (typically a job UUID).
135 # The repo can be a remote url, but in this case sha1 must already
136 # be present in our local cache for that repo: e.g., sha1 was just
137 # returned by find_commit_range.
138 def self.tag_in_internal_repository repo_name, sha1, tag
139 unless git_check_ref_format tag
140 raise ArgumentError.new "invalid tag #{tag}"
142 unless /^[0-9a-f]{40}$/ =~ sha1
143 raise ArgumentError.new "invalid sha1 #{sha1}"
145 src_gitdir, _ = git_dir_for repo_name
147 raise ArgumentError.new "no local repository for #{repo_name}"
149 dst_gitdir = Rails.configuration.Containers.JobsAPI.GitInternalDir
152 commit_in_dst = must_git(dst_gitdir, "log -n1 --format=%H #{sha1.shellescape}^{commit}").strip
154 commit_in_dst = false
157 tag_cmd = "tag --force #{tag.shellescape} #{sha1.shellescape}^{commit}"
158 if commit_in_dst == sha1
159 must_git(dst_gitdir, tag_cmd)
161 # git-fetch is faster than pack-objects|unpack-objects, but
162 # git-fetch can't fetch by sha1. So we first try to fetch a
163 # branch that has the desired commit, and if that fails (there
164 # is no such branch, or the branch we choose changes under us in
165 # race), we fall back to pack|unpack.
167 branches = must_git(src_gitdir,
168 "branch --contains #{sha1.shellescape}")
169 m = branches.match(/^. (\w+)\n/)
171 raise GitError.new "commit is not on any branch"
175 "fetch file://#{src_gitdir.shellescape} #{branch.shellescape}")
176 # Even if all of the above steps succeeded, we might still not
177 # have the right commit due to a race, in which case tag_cmd
178 # will fail, and we'll need to fall back to pack|unpack. So
179 # don't be tempted to condense this tag_cmd and the one in the
180 # rescue block into a single attempt.
181 must_git(dst_gitdir, tag_cmd)
183 must_pipe("echo #{sha1.shellescape}",
184 "git --git-dir #{src_gitdir.shellescape} pack-objects -q --revs --stdout",
185 "git --git-dir #{dst_gitdir.shellescape} unpack-objects -q")
186 must_git(dst_gitdir, tag_cmd)
193 def self.remote_url? repo_name
194 /^(https?|git):\/\// =~ repo_name
197 # Return [local_git_dir, is_remote]. If is_remote, caller must use
198 # fetch_remote_repository to ensure content is up-to-date.
200 # Raises an exception if the latest content could not be fetched for
202 def self.git_dir_for repo_name
203 if remote_url? repo_name
204 return [cache_dir_for(repo_name), true]
206 repos = Repository.readable_by(current_user).where(name: repo_name)
208 raise ArgumentError.new "Repository not found: '#{repo_name}'"
209 elsif repos.count > 1
210 Rails.logger.error "Multiple repositories with name=='#{repo_name}'!"
211 raise ArgumentError.new "Name conflict"
213 return [repos.first.server_path, false]
217 def self.cache_dir_for git_url
218 File.join(cache_dir_base, Digest::SHA1.hexdigest(git_url) + ".git").to_s
221 def self.cache_dir_base
222 Rails.root.join 'tmp', 'git-cache'
225 def self.fetch_remote_repository gitdir, git_url
226 # Caller decides which protocols are worth using. This is just a
227 # safety check to ensure we never use urls like "--flag" or wander
228 # into git's hardlink features by using bare "/path/foo" instead
229 # of "file:///path/foo".
230 unless /^[a-z]+:\/\// =~ git_url
231 raise ArgumentError.new "invalid git url #{git_url}"
234 must_git gitdir, "branch"
236 raise unless /Not a git repository/i =~ e.to_s
237 # OK, this just means we need to create a blank cache repository
239 FileUtils.mkdir_p gitdir
240 must_git gitdir, "init"
243 "fetch --no-progress --tags --prune --force --update-head-ok #{git_url.shellescape} 'refs/heads/*:refs/heads/*'")
246 def self.must_git gitdir, *cmds
247 # Clear token in case a git helper tries to use it as a password.
248 orig_token = ENV['ARVADOS_API_TOKEN']
249 ENV['ARVADOS_API_TOKEN'] = ''
252 git = "git --git-dir #{gitdir.shellescape}"
254 last_output = must_pipe git+" "+cmd
257 ENV['ARVADOS_API_TOKEN'] = orig_token
262 def self.must_pipe *cmds
263 cmd = cmds.join(" 2>&1 |") + " 2>&1"
264 out = IO.read("| </dev/null #{cmd}")
266 raise GitError.new "#{cmd}: #{$?}: #{out}"