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