Merge pull request #257 from alexmv/master
[puppet_vcsrepo.git] / lib / puppet / provider / vcsrepo / git.rb
1 require File.join(File.dirname(__FILE__), '..', 'vcsrepo')
2
3 Puppet::Type.type(:vcsrepo).provide(:git, :parent => Puppet::Provider::Vcsrepo) do
4   desc "Supports Git repositories"
5
6   has_command(:git, 'git') do
7     environment({ 'HOME' => ENV['HOME'] })
8   end
9
10   has_features :bare_repositories, :reference_tracking, :ssh_identity, :multiple_remotes, :user, :depth, :branch, :submodules
11
12   def create
13     if @resource.value(:revision) and @resource.value(:ensure) == :bare
14       fail("Cannot set a revision (#{@resource.value(:revision)}) on a bare repository")
15     end
16     if !@resource.value(:source)
17       init_repository(@resource.value(:path))
18     else
19       clone_repository(default_url, @resource.value(:path))
20       update_remotes
21
22       if @resource.value(:revision)
23         checkout
24       end
25       if @resource.value(:ensure) != :bare && @resource.value(:submodules) == :true
26         update_submodules
27       end
28
29     end
30     update_owner_and_excludes
31   end
32
33   def destroy
34     FileUtils.rm_rf(@resource.value(:path))
35   end
36
37   # Checks to see if the current revision is equal to the revision on the
38   # remote (whether on a branch, tag, or reference)
39   #
40   # @return [Boolean] Returns true if the repo is on the latest revision
41   def latest?
42     return revision == latest_revision
43   end
44
45   # Just gives the `should` value that we should be setting the repo to if
46   # latest? returns false
47   #
48   # @return [String] Returns the target sha/tag/branch
49   def latest
50     if not @resource.value(:revision) and branch = on_branch?
51       return branch
52     else
53       return @resource.value(:revision)
54     end
55   end
56
57   # Get the current revision of the repo (tag/branch/sha)
58   #
59   # @return [String] Returns the branch/tag if the current sha matches the
60   #                  remote; otherwise returns the current sha.
61   def revision
62     #HEAD is the default, but lets just be explicit here.
63     get_revision('HEAD')
64   end
65
66   # Is passed the desired reference, whether a tag, rev, or branch. Should
67   # handle transitions from a rev/branch/tag to a rev/branch/tag. Detached
68   # heads should be treated like bare revisions.
69   #
70   # @param [String] desired The desired revision to which the repo should be
71   #                         set.
72   def revision=(desired)
73     #just checkout tags and shas; fetch has already happened so they should be updated.
74     checkout(desired)
75     #branches require more work.
76     if local_branch_revision?(desired)
77       #reset instead of pull to avoid merge conflicts. assuming remote is
78       #updated and authoritative.
79       #TODO might be worthwhile to have an allow_local_changes param to decide
80       #whether to reset or pull when we're ensuring latest.
81       if @resource.value(:source)
82         at_path { git_with_identity('reset', '--hard', "#{@resource.value(:remote)}/#{desired}") }
83       else
84         at_path { git_with_identity('reset', '--hard', "#{desired}") }
85       end
86     end
87     #TODO Would this ever reach here if it is bare?
88     if @resource.value(:ensure) != :bare && @resource.value(:submodules) == :true
89       update_submodules
90     end
91     update_owner_and_excludes
92   end
93
94   def bare_exists?
95     bare_git_config_exists? && !working_copy_exists?
96   end
97
98   # If :source is set to a hash (for supporting multiple remotes),
99   # we search for the URL for :remote. If it doesn't exist,
100   # we throw an error. If :source is just a string, we use that
101   # value for the default URL.
102   def default_url
103     if @resource.value(:source).is_a?(Hash)
104       if @resource.value(:source).has_key?(@resource.value(:remote))
105         @resource.value(:source)[@resource.value(:remote)]
106       else
107         fail("You must specify the URL for #{@resource.value(:remote)} in the :source hash")
108       end
109     else
110       @resource.value(:source)
111     end
112   end
113
114   def working_copy_exists?
115     if @resource.value(:source) and File.exists?(File.join(@resource.value(:path), '.git', 'config'))
116       File.readlines(File.join(@resource.value(:path), '.git', 'config')).grep(/#{Regexp.escape(default_url)}/).any?
117     else
118       File.directory?(File.join(@resource.value(:path), '.git'))
119     end
120   end
121
122   def exists?
123     working_copy_exists? || bare_exists?
124   end
125
126   def update_remote_url(remote_name, remote_url)
127     do_update = false
128     current = git_with_identity('config', '-l')
129
130     unless remote_url.nil?
131       # Check if remote exists at all, regardless of URL.
132       # If remote doesn't exist, add it
133       if not current.include? "remote.#{remote_name}.url"
134         git_with_identity('remote','add', remote_name, remote_url)
135         return true
136
137       # If remote exists, but URL doesn't match, update URL
138       elsif not current.include? "remote.#{remote_name}.url=#{remote_url}"
139         git_with_identity('remote','set-url', remote_name, remote_url)
140         return true
141       else
142         return false
143       end
144     end
145
146   end
147
148   def update_remotes
149     do_update = false
150
151     # If supplied source is a hash of remote name and remote url pairs, then
152     # we loop around the hash. Otherwise, we assume single url specified
153     # in source property
154     if @resource.value(:source).is_a?(Hash)
155       @resource.value(:source).keys.sort.each do |remote_name|
156         remote_url = @resource.value(:source)[remote_name]
157         at_path { do_update |= update_remote_url(remote_name, remote_url) }
158       end
159     else
160       at_path { do_update |= update_remote_url(@resource.value(:remote), @resource.value(:source)) }
161     end
162
163     # If at least one remote was added or updated, then we must
164     # call the 'git remote update' command
165     if do_update == true
166       at_path { git_with_identity('remote','update') }
167     end
168
169   end
170
171   def update_references
172     at_path do
173       update_remotes
174       git_with_identity('fetch', @resource.value(:remote))
175       git_with_identity('fetch', '--tags', @resource.value(:remote))
176       update_owner_and_excludes
177     end
178   end
179
180   private
181
182   # @!visibility private
183   def bare_git_config_exists?
184     File.exist?(File.join(@resource.value(:path), 'config'))
185   end
186
187   # @!visibility private
188   def clone_repository(source, path)
189     check_force
190     args = ['clone']
191     if @resource.value(:depth) and @resource.value(:depth).to_i > 0
192       args.push('--depth', @resource.value(:depth).to_s)
193       if @resource.value(:revision)
194         args.push('--branch', @resource.value(:revision).to_s)
195       end
196     end
197     if @resource.value(:branch)
198       args.push('--branch', @resource.value(:branch).to_s)
199     end
200     if @resource.value(:ensure) == :bare
201       args << '--bare'
202     end
203     if @resource.value(:remote) != 'origin'
204       args.push('--origin', @resource.value(:remote))
205     end
206     if !working_copy_exists?
207       args.push(source, path)
208       Dir.chdir("/") do
209         git_with_identity(*args)
210       end
211     else
212       notice "Repo has already been cloned"
213     end
214   end
215
216   # @!visibility private
217   def check_force
218     if path_exists? and not path_empty?
219       if @resource.value(:force)
220         notice "Removing %s to replace with vcsrepo." % @resource.value(:path)
221         destroy
222       else
223         raise Puppet::Error, "Could not create repository (non-repository at path)"
224       end
225     end
226   end
227
228   # @!visibility private
229   def init_repository(path)
230     check_force
231     if @resource.value(:ensure) == :bare && working_copy_exists?
232       convert_working_copy_to_bare
233     elsif @resource.value(:ensure) == :present && bare_exists?
234       convert_bare_to_working_copy
235     else
236       # normal init
237       FileUtils.mkdir(@resource.value(:path))
238       FileUtils.chown(@resource.value(:user), nil, @resource.value(:path)) if @resource.value(:user)
239       args = ['init']
240       if @resource.value(:ensure) == :bare
241         args << '--bare'
242       end
243       at_path do
244         git_with_identity(*args)
245       end
246     end
247   end
248
249   # Convert working copy to bare
250   #
251   # Moves:
252   #   <path>/.git
253   # to:
254   #   <path>/
255   # @!visibility private
256   def convert_working_copy_to_bare
257     notice "Converting working copy repository to bare repository"
258     FileUtils.mv(File.join(@resource.value(:path), '.git'), tempdir)
259     FileUtils.rm_rf(@resource.value(:path))
260     FileUtils.mv(tempdir, @resource.value(:path))
261   end
262
263   # Convert bare to working copy
264   #
265   # Moves:
266   #   <path>/
267   # to:
268   #   <path>/.git
269   # @!visibility private
270   def convert_bare_to_working_copy
271     notice "Converting bare repository to working copy repository"
272     FileUtils.mv(@resource.value(:path), tempdir)
273     FileUtils.mkdir(@resource.value(:path))
274     FileUtils.mv(tempdir, File.join(@resource.value(:path), '.git'))
275     if commits_in?(File.join(@resource.value(:path), '.git'))
276       reset('HEAD')
277       git_with_identity('checkout', '--force')
278       update_owner_and_excludes
279     end
280   end
281
282   # @!visibility private
283   def commits_in?(dot_git)
284     Dir.glob(File.join(dot_git, 'objects/info/*'), File::FNM_DOTMATCH) do |e|
285       return true unless %w(. ..).include?(File::basename(e))
286     end
287     false
288   end
289
290   # Will checkout a rev/branch/tag using the locally cached versions. Does not
291   # handle upstream branch changes
292   # @!visibility private
293   def checkout(revision = @resource.value(:revision))
294     if !local_branch_revision?(revision) && remote_branch_revision?(revision)
295       #non-locally existant branches (perhaps switching to a branch that has never been checked out)
296       at_path { git_with_identity('checkout', '--force', '-b', revision, '--track', "#{@resource.value(:remote)}/#{revision}") }
297     else
298       #tags, locally existant branches (perhaps outdated), and shas
299       at_path { git_with_identity('checkout', '--force', revision) }
300     end
301   end
302
303   # @!visibility private
304   def reset(desired)
305     at_path do
306       git_with_identity('reset', '--hard', desired)
307     end
308   end
309
310   # @!visibility private
311   def update_submodules
312     at_path do
313       git_with_identity('submodule', 'update', '--init', '--recursive')
314     end
315   end
316
317   # Determins if the branch exists at the upstream but has not yet been locally committed
318   # @!visibility private
319   def remote_branch_revision?(revision = @resource.value(:revision))
320     # git < 1.6 returns '#{@resource.value(:remote)}/#{revision}'
321     # git 1.6+ returns 'remotes/#{@resource.value(:remote)}/#{revision}'
322     branch = at_path { branches.grep /(remotes\/)?#{@resource.value(:remote)}\/#{revision}/ }
323     branch unless branch.empty?
324   end
325
326   # Determins if the branch is already cached locally
327   # @!visibility private
328   def local_branch_revision?(revision = @resource.value(:revision))
329     at_path { branches.include?(revision) }
330   end
331
332   # @!visibility private
333   def tag_revision?(revision = @resource.value(:revision))
334     at_path { tags.include?(revision) }
335   end
336
337   # @!visibility private
338   def branches
339     at_path { git_with_identity('branch', '-a') }.gsub('*', ' ').split(/\n/).map { |line| line.strip }
340   end
341
342   # git < 2.4 returns 'detached from'
343   # git 2.4+ returns 'HEAD detached at'
344   # @!visibility private
345   def on_branch?
346     at_path {
347       matches = git_with_identity('branch', '-a').match /\*\s+(.*)/
348       matches[1] unless matches[1].match /(\(detached from|\(HEAD detached at|\(no branch)/
349     }
350   end
351
352   # @!visibility private
353   def tags
354     at_path { git_with_identity('tag', '-l') }.split(/\n/).map { |line| line.strip }
355   end
356
357   # @!visibility private
358   def set_excludes
359     # Excludes may be an Array or a String.
360     at_path do
361       open('.git/info/exclude', 'w') do |f|
362         if @resource.value(:excludes).respond_to?(:each)
363           @resource.value(:excludes).each { |ex| f.puts ex }
364         else
365           f.puts @resource.value(:excludes)
366         end
367       end
368     end
369   end
370
371   # Finds the latest revision or sha of the current branch if on a branch, or
372   # of HEAD otherwise.
373   # @note Calls create which can forcibly destroy and re-clone the repo if
374   #       force => true
375   # @see get_revision
376   #
377   # @!visibility private
378   # @return [String] Returns the output of get_revision
379   def latest_revision
380     #TODO Why is create called here anyway?
381     create if @resource.value(:force) && working_copy_exists?
382     create if !working_copy_exists?
383
384     if branch = on_branch?
385       return get_revision("#{@resource.value(:remote)}/#{branch}")
386     else
387       return get_revision
388     end
389   end
390
391   # Returns the current revision given if the revision is a tag or branch and
392   # matches the current sha. If the current sha does not match the sha of a tag
393   # or branch, then it will just return the sha (ie, is not in sync)
394   #
395   # @!visibility private
396   #
397   # @param [String] rev The revision of which to check if it is current
398   # @return [String] Returns the tag/branch of the current repo if it's up to
399   #                  date; otherwise returns the sha of the requested revision.
400   def get_revision(rev = 'HEAD')
401     if @resource.value(:source)
402       update_references
403     else
404       status = at_path { git_with_identity('status')}
405       is_it_new = status =~ /Initial commit/
406       if is_it_new
407         status =~ /On branch (.*)/
408         branch = $1
409         return branch
410       end
411     end
412     current = at_path { git_with_identity('rev-parse', rev).strip }
413     if @resource.value(:revision)
414       if tag_revision?
415         # git-rev-parse will give you the hash of the tag object itself rather
416         # than the commit it points to by default. Using tag^0 will return the
417         # actual commit.
418         canonical = at_path { git_with_identity('rev-parse', "#{@resource.value(:revision)}^0").strip }
419       elsif local_branch_revision?
420         canonical = at_path { git_with_identity('rev-parse', @resource.value(:revision)).strip }
421       elsif remote_branch_revision?
422         canonical = at_path { git_with_identity('rev-parse', "#{@resource.value(:remote)}/#{@resource.value(:revision)}").strip }
423       else
424         #look for a sha (could match invalid shas)
425         canonical = at_path { git_with_identity('rev-parse', '--revs-only', @resource.value(:revision)).strip }
426       end
427       fail("#{@resource.value(:revision)} is not a local or remote ref") if canonical.nil? or canonical.empty?
428       current = @resource.value(:revision) if current == canonical
429     end
430     return current
431   end
432
433   # @!visibility private
434   def update_owner_and_excludes
435     if @resource.value(:owner) or @resource.value(:group)
436       set_ownership
437     end
438     if @resource.value(:excludes)
439       set_excludes
440     end
441   end
442
443   # @!visibility private
444   def git_with_identity(*args)
445     if @resource.value(:identity)
446       Tempfile.open('git-helper', Puppet[:statedir]) do |f|
447         f.puts '#!/bin/sh'
448         f.puts 'export SSH_AUTH_SOCKET='
449         f.puts "exec ssh -oStrictHostKeyChecking=no -oPasswordAuthentication=no -oKbdInteractiveAuthentication=no -oChallengeResponseAuthentication=no -oConnectTimeout=120 -i #{@resource.value(:identity)} $*"
450         f.close
451
452         FileUtils.chmod(0755, f.path)
453         env_save = ENV['GIT_SSH']
454         ENV['GIT_SSH'] = f.path
455
456         ret = git(*args)
457
458         ENV['GIT_SSH'] = env_save
459
460         return ret
461       end
462     elsif @resource.value(:user) and @resource.value(:user) != Facter['id'].value
463       env = Etc.getpwnam(@resource.value(:user))
464       Puppet::Util::Execution.execute("git #{args.join(' ')}", :uid => @resource.value(:user), :failonfail => true, :custom_environment => {'HOME' => env['dir']})
465     else
466       git(*args)
467     end
468   end
469 end