1 class Commit < ActiveRecord::Base
2 extend CurrentApiClient
4 class GitError < StandardError
10 def self.git_check_ref_format(e)
11 if !e or e.empty? or e[0] == '-' or e[0] == '$'
12 # definitely not valid
15 `git check-ref-format --allow-onelevel #{e.shellescape}`
20 # Return an array of commits (each a 40-char sha1) satisfying the
23 # Return [] if the revisions given in minimum/maximum are invalid or
24 # don't exist in the given repository.
26 # Raise ArgumentError if the given repository is invalid, does not
27 # exist, or cannot be read for any reason. (Any transient error that
28 # prevents commit ranges from resolving must raise rather than
29 # returning an empty array.)
31 # repository can be the name of a locally hosted repository or a git
32 # URL (see git-fetch(1)). Currently http, https, and git schemes are
34 def self.find_commit_range repository, minimum, maximum, exclude
35 if minimum and minimum.empty?
39 if minimum and !git_check_ref_format(minimum)
40 logger.warn "find_commit_range called with invalid minimum revision: '#{minimum}'"
44 if maximum and !git_check_ref_format(maximum)
45 logger.warn "find_commit_range called with invalid maximum revision: '#{maximum}'"
53 gitdir, is_remote = git_dir_for repository
54 fetch_remote_repository gitdir, repository if is_remote
55 ENV['GIT_DIR'] = gitdir
59 # Get the commit hash for the upper bound
61 IO.foreach("|git rev-list --max-count=1 #{maximum.shellescape} --") do |line|
65 # If not found or string is invalid, nothing else to do
66 return [] if !max_hash or !git_check_ref_format(max_hash)
68 resolved_exclude = nil
72 if git_check_ref_format(e)
73 IO.foreach("|git rev-list --max-count=1 #{e.shellescape} --") do |line|
74 resolved_exclude.push(line.strip)
77 logger.warn "find_commit_range called with invalid exclude invalid characters: '#{exclude}'"
84 # Get the commit hash for the lower bound
86 IO.foreach("|git rev-list --max-count=1 #{minimum.shellescape} --") do |line|
90 # If not found or string is invalid, nothing else to do
91 return [] if !min_hash or !git_check_ref_format(min_hash)
93 # Now find all commits between them
94 IO.foreach("|git rev-list #{min_hash.shellescape}..#{max_hash.shellescape} --") do |line|
96 commits.push(hash) if !resolved_exclude or !resolved_exclude.include? hash
99 commits.push(min_hash) if !resolved_exclude or !resolved_exclude.include? min_hash
101 commits.push(max_hash) if !resolved_exclude or !resolved_exclude.include? max_hash
107 # Given a repository (url, or name of hosted repo) and commit sha1,
108 # copy the commit into the internal git repo and tag it with the
109 # given tag (typically a job UUID).
111 # The repo can be a remote url, but in this case sha1 must already
112 # be present in our local cache for that repo: e.g., sha1 was just
113 # returned by find_commit_range.
114 def self.tag_in_internal_repository repo_name, sha1, tag
115 unless git_check_ref_format tag
116 raise ArgumentError.new "invalid tag #{tag}"
118 unless /^[0-9a-f]{40}$/ =~ sha1
119 raise ArgumentError.new "invalid sha1 #{sha1}"
121 src_gitdir, _ = git_dir_for repo_name
123 raise ArgumentError.new "no local repository for #{repo_name}"
125 dst_gitdir = Rails.configuration.git_internal_dir
126 must_pipe("echo #{sha1.shellescape}",
127 "git --git-dir #{src_gitdir.shellescape} pack-objects -q --revs --stdout",
128 "git --git-dir #{dst_gitdir.shellescape} unpack-objects -q")
130 "tag --force #{tag.shellescape} #{sha1.shellescape}")
135 def self.remote_url? repo_name
136 /^(https?|git):\/\// =~ repo_name
139 # Return [local_git_dir, is_remote]. If is_remote, caller must use
140 # fetch_remote_repository to ensure content is up-to-date.
142 # Raises an exception if the latest content could not be fetched for
144 def self.git_dir_for repo_name
145 if remote_url? repo_name
146 return [cache_dir_for(repo_name), true]
148 repos = Repository.readable_by(current_user).where(name: repo_name)
150 raise ArgumentError.new "Repository not found: '#{repo_name}'"
151 elsif repos.count > 1
152 logger.error "Multiple repositories with name=='#{repo_name}'!"
153 raise ArgumentError.new "Name conflict"
155 return [repos.first.server_path, false]
159 def self.cache_dir_for git_url
160 File.join(cache_dir_base, Digest::SHA1.hexdigest(git_url) + ".git").to_s
163 def self.cache_dir_base
164 Rails.root.join 'tmp', 'git'
167 def self.fetch_remote_repository gitdir, git_url
168 # Caller decides which protocols are worth using. This is just a
169 # safety check to ensure we never use urls like "--flag" or wander
170 # into git's hardlink features by using bare "/path/foo" instead
171 # of "file:///path/foo".
172 unless /^[a-z]+:\/\// =~ git_url
173 raise ArgumentError.new "invalid git url #{git_url}"
176 must_git gitdir, "branch"
178 raise unless /Not a git repository/ =~ e.to_s
179 # OK, this just means we need to create a blank cache repository
181 FileUtils.mkdir_p gitdir
182 must_git gitdir, "init"
185 "fetch --no-progress --tags --prune --force --update-head-ok #{git_url.shellescape} 'refs/heads/*:refs/heads/*'")
188 def self.must_git gitdir, *cmds
189 # Clear token in case a git helper tries to use it as a password.
190 orig_token = ENV['ARVADOS_API_TOKEN']
191 ENV['ARVADOS_API_TOKEN'] = ''
193 git = "git --git-dir #{gitdir.shellescape}"
195 must_pipe git+" "+cmd
198 ENV['ARVADOS_API_TOKEN'] = orig_token
202 def self.must_pipe *cmds
203 cmd = cmds.join(" 2>&1 |") + " 2>&1"
204 out = IO.read("| </dev/null #{cmd}")
206 raise GitError.new "#{cmd}: #{$?}: #{out}"