Merge pull request #187 from p4paul/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   ##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     at_path { open('.git/info/exclude', 'w') { |f| @resource.value(:excludes).each { |ex| f.write(ex + "\n") }}}
285   end
286
287   # Finds the latest revision or sha of the current branch if on a branch, or
288   # of HEAD otherwise.
289   # @note Calls create which can forcibly destroy and re-clone the repo if
290   #       force => true
291   # @see get_revision
292   #
293   # @!visibility private
294   # @return [String] Returns the output of get_revision
295   def latest_revision
296     #TODO Why is create called here anyway?
297     create if @resource.value(:force) && working_copy_exists?
298     create if !working_copy_exists?
299
300     if branch = on_branch?
301       return get_revision("#{@resource.value(:remote)}/#{branch}")
302     else
303       return get_revision
304     end
305   end
306
307   # Returns the current revision given if the revision is a tag or branch and
308   # matches the current sha. If the current sha does not match the sha of a tag
309   # or branch, then it will just return the sha (ie, is not in sync)
310   #
311   # @!visibility private
312   #
313   # @param [String] rev The revision of which to check if it is current
314   # @return [String] Returns the tag/branch of the current repo if it's up to
315   #                  date; otherwise returns the sha of the requested revision.
316   def get_revision(rev = 'HEAD')
317     update_references
318     current = at_path { git_with_identity('rev-parse', rev).strip }
319     if @resource.value(:revision)
320       if tag_revision?
321         # git-rev-parse will give you the hash of the tag object itself rather
322         # than the commit it points to by default. Using tag^0 will return the
323         # actual commit.
324         canonical = at_path { git_with_identity('rev-parse', "#{@resource.value(:revision)}^0").strip }
325       elsif local_branch_revision?
326         canonical = at_path { git_with_identity('rev-parse', @resource.value(:revision)).strip }
327       elsif remote_branch_revision?
328         canonical = at_path { git_with_identity('rev-parse', "#{@resource.value(:remote)}/#{@resource.value(:revision)}").strip }
329       else
330         #look for a sha (could match invalid shas)
331         canonical = at_path { git_with_identity('rev-parse', '--revs-only', @resource.value(:revision)).strip }
332       end
333       fail("#{@resource.value(:revision)} is not a local or remote ref") if canonical.nil? or canonical.empty?
334       current = @resource.value(:revision) if current == canonical
335     end
336     return current
337   end
338
339   # @!visibility private
340   def update_owner_and_excludes
341     if @resource.value(:owner) or @resource.value(:group)
342       set_ownership
343     end
344     if @resource.value(:excludes)
345       set_excludes
346     end
347   end
348
349   # @!visibility private
350   def git_with_identity(*args)
351     if @resource.value(:identity)
352       Tempfile.open('git-helper') do |f|
353         f.puts '#!/bin/sh'
354         f.puts "exec ssh -oStrictHostKeyChecking=no -oPasswordAuthentication=no -oKbdInteractiveAuthentication=no -oChallengeResponseAuthentication=no -oConnectTimeout=120 -i #{@resource.value(:identity)} $*"
355         f.close
356
357         FileUtils.chmod(0755, f.path)
358         env_save = ENV['GIT_SSH']
359         ENV['GIT_SSH'] = f.path
360
361         ret = git(*args)
362
363         ENV['GIT_SSH'] = env_save
364
365         return ret
366       end
367     elsif @resource.value(:user) and @resource.value(:user) != Facter['id'].value
368       su(@resource.value(:user), '-c', "git #{args.join(' ')}" )
369     else
370       git(*args)
371     end
372   end
373 end