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