Merge branch '10812-cwl-package-update' refs #10812
[arvados.git] / services / api / app / models / commit.rb
1 class Commit < ActiveRecord::Base
2   extend CurrentApiClient
3
4   class GitError < StandardError
5     def http_status
6       422
7     end
8   end
9
10   def self.git_check_ref_format(e)
11     if !e or e.empty? or e[0] == '-' or e[0] == '$'
12       # definitely not valid
13       false
14     else
15       `git check-ref-format --allow-onelevel #{e.shellescape}`
16       $?.success?
17     end
18   end
19
20   # Return an array of commits (each a 40-char sha1) satisfying the
21   # given criteria.
22   #
23   # Return [] if the revisions given in minimum/maximum are invalid or
24   # don't exist in the given repository.
25   #
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.)
30   #
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
33   # supported.
34   def self.find_commit_range repository, minimum, maximum, exclude
35     if minimum and minimum.empty?
36       minimum = nil
37     end
38
39     if minimum and !git_check_ref_format(minimum)
40       logger.warn "find_commit_range called with invalid minimum revision: '#{minimum}'"
41       return []
42     end
43
44     if maximum and !git_check_ref_format(maximum)
45       logger.warn "find_commit_range called with invalid maximum revision: '#{maximum}'"
46       return []
47     end
48
49     if !maximum
50       maximum = "HEAD"
51     end
52
53     gitdir, is_remote = git_dir_for repository
54     fetch_remote_repository gitdir, repository if is_remote
55     ENV['GIT_DIR'] = gitdir
56
57     commits = []
58
59     # Get the commit hash for the upper bound
60     max_hash = nil
61     git_max_hash_cmd = "git rev-list --max-count=1 #{maximum.shellescape} --"
62     IO.foreach("|#{git_max_hash_cmd}") do |line|
63       max_hash = line.strip
64     end
65
66     # If not found, nothing else to do
67     if !max_hash
68       logger.warn "no refs found looking for max_hash: `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` returned no output"
69       return []
70     end
71
72     # If string is invalid, nothing else to do
73     if !git_check_ref_format(max_hash)
74       logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` was invalid for max_hash: #{max_hash}"
75       return []
76     end
77
78     resolved_exclude = nil
79     if exclude
80       resolved_exclude = []
81       exclude.each do |e|
82         if git_check_ref_format(e)
83           IO.foreach("|git rev-list --max-count=1 #{e.shellescape} --") do |line|
84             resolved_exclude.push(line.strip)
85           end
86         else
87           logger.warn "find_commit_range called with invalid exclude invalid characters: '#{exclude}'"
88           return []
89         end
90       end
91     end
92
93     if minimum
94       # Get the commit hash for the lower bound
95       min_hash = nil
96       git_min_hash_cmd = "git rev-list --max-count=1 #{minimum.shellescape} --"
97       IO.foreach("|#{git_min_hash_cmd}") do |line|
98         min_hash = line.strip
99       end
100
101       # If not found, nothing else to do
102       if !min_hash
103         logger.warn "no refs found looking for min_hash: `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` returned no output"
104         return []
105       end
106
107       # If string is invalid, nothing else to do
108       if !git_check_ref_format(min_hash)
109         logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` was invalid for min_hash: #{min_hash}"
110         return []
111       end
112
113       # Now find all commits between them
114       IO.foreach("|git rev-list #{min_hash.shellescape}..#{max_hash.shellescape} --") do |line|
115         hash = line.strip
116         commits.push(hash) if !resolved_exclude or !resolved_exclude.include? hash
117       end
118
119       commits.push(min_hash) if !resolved_exclude or !resolved_exclude.include? min_hash
120     else
121       commits.push(max_hash) if !resolved_exclude or !resolved_exclude.include? max_hash
122     end
123
124     commits
125   end
126
127   # Given a repository (url, or name of hosted repo) and commit sha1,
128   # copy the commit into the internal git repo and tag it with the
129   # given tag (typically a job UUID).
130   #
131   # The repo can be a remote url, but in this case sha1 must already
132   # be present in our local cache for that repo: e.g., sha1 was just
133   # returned by find_commit_range.
134   def self.tag_in_internal_repository repo_name, sha1, tag
135     unless git_check_ref_format tag
136       raise ArgumentError.new "invalid tag #{tag}"
137     end
138     unless /^[0-9a-f]{40}$/ =~ sha1
139       raise ArgumentError.new "invalid sha1 #{sha1}"
140     end
141     src_gitdir, _ = git_dir_for repo_name
142     unless src_gitdir
143       raise ArgumentError.new "no local repository for #{repo_name}"
144     end
145     dst_gitdir = Rails.configuration.git_internal_dir
146     must_pipe("echo #{sha1.shellescape}",
147               "git --git-dir #{src_gitdir.shellescape} pack-objects -q --revs --stdout",
148               "git --git-dir #{dst_gitdir.shellescape} unpack-objects -q")
149     must_git(dst_gitdir,
150              "tag --force #{tag.shellescape} #{sha1.shellescape}")
151   end
152
153   protected
154
155   def self.remote_url? repo_name
156     /^(https?|git):\/\// =~ repo_name
157   end
158
159   # Return [local_git_dir, is_remote]. If is_remote, caller must use
160   # fetch_remote_repository to ensure content is up-to-date.
161   #
162   # Raises an exception if the latest content could not be fetched for
163   # any reason.
164   def self.git_dir_for repo_name
165     if remote_url? repo_name
166       return [cache_dir_for(repo_name), true]
167     end
168     repos = Repository.readable_by(current_user).where(name: repo_name)
169     if repos.count == 0
170       raise ArgumentError.new "Repository not found: '#{repo_name}'"
171     elsif repos.count > 1
172       logger.error "Multiple repositories with name=='#{repo_name}'!"
173       raise ArgumentError.new "Name conflict"
174     else
175       return [repos.first.server_path, false]
176     end
177   end
178
179   def self.cache_dir_for git_url
180     File.join(cache_dir_base, Digest::SHA1.hexdigest(git_url) + ".git").to_s
181   end
182
183   def self.cache_dir_base
184     Rails.root.join 'tmp', 'git'
185   end
186
187   def self.fetch_remote_repository gitdir, git_url
188     # Caller decides which protocols are worth using. This is just a
189     # safety check to ensure we never use urls like "--flag" or wander
190     # into git's hardlink features by using bare "/path/foo" instead
191     # of "file:///path/foo".
192     unless /^[a-z]+:\/\// =~ git_url
193       raise ArgumentError.new "invalid git url #{git_url}"
194     end
195     begin
196       must_git gitdir, "branch"
197     rescue GitError => e
198       raise unless /Not a git repository/ =~ e.to_s
199       # OK, this just means we need to create a blank cache repository
200       # before fetching.
201       FileUtils.mkdir_p gitdir
202       must_git gitdir, "init"
203     end
204     must_git(gitdir,
205              "fetch --no-progress --tags --prune --force --update-head-ok #{git_url.shellescape} 'refs/heads/*:refs/heads/*'")
206   end
207
208   def self.must_git gitdir, *cmds
209     # Clear token in case a git helper tries to use it as a password.
210     orig_token = ENV['ARVADOS_API_TOKEN']
211     ENV['ARVADOS_API_TOKEN'] = ''
212     begin
213       git = "git --git-dir #{gitdir.shellescape}"
214       cmds.each do |cmd|
215         must_pipe git+" "+cmd
216       end
217     ensure
218       ENV['ARVADOS_API_TOKEN'] = orig_token
219     end
220   end
221
222   def self.must_pipe *cmds
223     cmd = cmds.join(" 2>&1 |") + " 2>&1"
224     out = IO.read("| </dev/null #{cmd}")
225     if not $?.success?
226       raise GitError.new "#{cmd}: #{$?}: #{out}"
227     end
228   end
229 end