chiark / gitweb /
[PATCH] Support .gitignore
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, glob, popen2
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27     pass
28
29
30 # Different start-up variables read from the environment
31 if 'GIT_DIR' in os.environ:
32     base_dir = os.environ['GIT_DIR']
33 else:
34     base_dir = '.git'
35
36 head_link = os.path.join(base_dir, 'HEAD')
37
38 #
39 # Classes
40 #
41 class Commit:
42     """Handle the commit objects
43     """
44     def __init__(self, id_hash):
45         self.__id_hash = id_hash
46
47         lines = _output_lines('git-cat-file commit %s' % id_hash)
48         for i in range(len(lines)):
49             line = lines[i]
50             if line == '\n':
51                 break
52             field = line.strip().split(' ', 1)
53             if field[0] == 'tree':
54                 self.__tree = field[1]
55             elif field[0] == 'parent':
56                 self.__parent = field[1]
57             if field[0] == 'author':
58                 self.__author = field[1]
59             if field[0] == 'comitter':
60                 self.__committer = field[1]
61         self.__log = ''.join(lines[i:])
62
63     def get_id_hash(self):
64         return self.__id_hash
65
66     def get_tree(self):
67         return self.__tree
68
69     def get_parent(self):
70         return self.__parent
71
72     def get_author(self):
73         return self.__author
74
75     def get_committer(self):
76         return self.__committer
77
78 # dictionary of Commit objects, used to avoid multiple calls to git
79 __commits = dict()
80
81 #
82 # Functions
83 #
84 def get_commit(id_hash):
85     """Commit objects factory. Save/look-up them in the __commits
86     dictionary
87     """
88     if id_hash in __commits:
89         return __commits[id_hash]
90     else:
91         commit = Commit(id_hash)
92         __commits[id_hash] = commit
93         return commit
94
95 def get_conflicts():
96     """Return the list of file conflicts
97     """
98     conflicts_file = os.path.join(base_dir, 'conflicts')
99     if os.path.isfile(conflicts_file):
100         f = file(conflicts_file)
101         names = [line.strip() for line in f.readlines()]
102         f.close()
103         return names
104     else:
105         return None
106
107 def _input(cmd, file_desc):
108     p = popen2.Popen3(cmd)
109     for line in file_desc:
110         p.tochild.write(line)
111     p.tochild.close()
112     if p.wait():
113         raise GitException, '%s failed' % str(cmd)
114
115 def _output(cmd):
116     p=popen2.Popen3(cmd)
117     string = p.fromchild.read()
118     if p.wait():
119         raise GitException, '%s failed' % str(cmd)
120     return string
121
122 def _output_one_line(cmd):
123     p=popen2.Popen3(cmd)
124     string = p.fromchild.readline().strip()
125     if p.wait():
126         raise GitException, '%s failed' % str(cmd)
127     return string
128
129 def _output_lines(cmd):
130     p=popen2.Popen3(cmd)
131     lines = p.fromchild.readlines()
132     if p.wait():
133         raise GitException, '%s failed' % str(cmd)
134     return lines
135
136 def __run(cmd, args=None):
137     """__run: runs cmd using spawnvp.
138
139     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
140     our arguments.  If args is very large, the command is run multiple
141     times; args is split xargs style: cmd is passed on each
142     invocation.  Unlike xargs, returns immediately if any non-zero
143     return code is received.  
144     """
145     
146     args_l=cmd.split()
147     if args is None:
148         args = []
149     for i in range(0, len(args)+1, 100):
150         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
151     if r:
152         return r
153     return 0
154
155 def __check_base_dir():
156     return os.path.isdir(base_dir)
157
158 def __tree_status(files = [], tree_id = 'HEAD', unknown = False, noexclude = True):
159     """Returns a list of pairs - [status, filename]
160     """
161     os.system('git-update-cache --refresh > /dev/null')
162
163     cache_files = []
164
165     # unknown files
166     if unknown:
167         exclude_file = os.path.join(base_dir, 'exclude')
168         base_exclude = [ '--exclude=*.[ao]', '--exclude=.*' '--exclude=TAGS',
169                         '--exclude=tags', '--exclude=*~', '--exclude=#*',
170                         '--exclude-per-directory=.gitignore' ]
171         extra_exclude = []
172         if os.path.exists(exclude_file):
173             extra_exclude.append('--exclude-from=%s' % exclude_file)
174         if noexclude:
175             extra_exclude = base_exclude = []
176         lines = _output_lines(['git-ls-files', '--others' ] + base_exclude
177                         + extra_exclude)
178         cache_files += [('?', line.strip()) for line in lines]
179
180     # conflicted files
181     conflicts = get_conflicts()
182     if not conflicts:
183         conflicts = []
184     cache_files += [('C', filename) for filename in conflicts]
185
186     # the rest
187     for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
188         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
189         if fs[1] not in conflicts:
190             cache_files.append(fs)
191
192     return cache_files
193
194 def local_changes():
195     """Return true if there are local changes in the tree
196     """
197     return len(__tree_status()) != 0
198
199 def get_head():
200     """Returns a string representing the HEAD
201     """
202     return read_string(head_link)
203
204 def get_head_file():
205     """Returns the name of the file pointed to by the HEAD link
206     """
207     # valid link
208     if os.path.islink(head_link) and os.path.isfile(head_link):
209         return os.path.basename(os.readlink(head_link))
210     else:
211         raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
212
213 def __set_head(val):
214     """Sets the HEAD value
215     """
216     write_string(head_link, val)
217
218 def add(names):
219     """Add the files or recursively add the directory contents
220     """
221     # generate the file list
222     files = []
223     for i in names:
224         if not os.path.exists(i):
225             raise GitException, 'Unknown file or directory: %s' % i
226
227         if os.path.isdir(i):
228             # recursive search. We only add files
229             for root, dirs, local_files in os.walk(i):
230                 for name in [os.path.join(root, f) for f in local_files]:
231                     if os.path.isfile(name):
232                         files.append(os.path.normpath(name))
233         elif os.path.isfile(i):
234             files.append(os.path.normpath(i))
235         else:
236             raise GitException, '%s is not a file or directory' % i
237
238     if files:
239         if __run('git-update-cache --add --', files):
240             raise GitException, 'Unable to add file'
241
242 def rm(files, force = False):
243     """Remove a file from the repository
244     """
245     if force:
246         git_opt = '--force-remove'
247     else:
248         git_opt = '--remove'
249
250     if not force:
251         for f in files:
252             if os.path.exists(f):
253                 raise GitException, '%s exists. Remove it first' %f
254         if files:
255             __run('git-update-cache --remove --', files)
256     else:
257         if files:
258             __run('git-update-cache --force-remove --', files)
259
260 def update_cache(files = [], force = False):
261     """Update the cache information for the given files
262     """
263     cache_files = __tree_status(files)
264
265     # everything is up-to-date
266     if len(cache_files) == 0:
267         return False
268
269     # check for unresolved conflicts
270     if not force and [x for x in cache_files
271                       if x[0] not in ['M', 'N', 'A', 'D']]:
272         raise GitException, 'Updating cache failed: unresolved conflicts'
273
274     # update the cache
275     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
276     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
277     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
278
279     if add_files and __run('git-update-cache --add --', add_files) != 0:
280         raise GitException, 'Failed git-update-cache --add'
281     if rm_files and __run('git-update-cache --force-remove --', rm_files) != 0:
282         raise GitException, 'Failed git-update-cache --rm'
283     if m_files and __run('git-update-cache --', m_files) != 0:
284         raise GitException, 'Failed git-update-cache'
285
286     return True
287
288 def commit(message, files = [], parents = [], allowempty = False,
289            cache_update = True,
290            author_name = None, author_email = None, author_date = None,
291            committer_name = None, committer_email = None):
292     """Commit the current tree to repository
293     """
294     # Get the tree status
295     if cache_update and parents != []:
296         changes = update_cache(files)
297         if not changes and not allowempty:
298             raise GitException, 'No changes to commit'
299
300     # get the commit message
301     f = file('.commitmsg', 'w+')
302     if message[-1] == '\n':
303         f.write(message)
304     else:
305         print >> f, message
306     f.close()
307
308     # write the index to repository
309     tree_id = _output_one_line('git-write-tree')
310
311     # the commit
312     cmd = ''
313     if author_name:
314         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
315     if author_email:
316         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
317     if author_date:
318         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
319     if committer_name:
320         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
321     if committer_email:
322         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
323     cmd += 'git-commit-tree %s' % tree_id
324
325     # get the parents
326     for p in parents:
327         cmd += ' -p %s' % p
328
329     cmd += ' < .commitmsg'
330
331     commit_id = _output_one_line(cmd)
332     __set_head(commit_id)
333     os.remove('.commitmsg')
334
335     return commit_id
336
337 def merge(base, head1, head2):
338     """Perform a 3-way merge between base, head1 and head2 into the
339     local tree
340     """
341     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
342         raise GitException, 'git-read-tree failed (local changes maybe?)'
343
344     # this can fail if there are conflicts
345     if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
346         raise GitException, 'git-merge-cache failed (possible conflicts)'
347
348 def status(files = [], modified = False, new = False, deleted = False,
349            conflict = False, unknown = False, noexclude = False):
350     """Show the tree status
351     """
352     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
353     all = not (modified or new or deleted or conflict or unknown)
354
355     if not all:
356         filestat = []
357         if modified:
358             filestat.append('M')
359         if new:
360             filestat.append('A')
361             filestat.append('N')
362         if deleted:
363             filestat.append('D')
364         if conflict:
365             filestat.append('C')
366         if unknown:
367             filestat.append('?')
368         cache_files = [x for x in cache_files if x[0] in filestat]
369
370     for fs in cache_files:
371         if all:
372             print '%s %s' % (fs[0], fs[1])
373         else:
374             print '%s' % fs[1]
375
376 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
377     """Show the diff between rev1 and rev2
378     """
379     os.system('git-update-cache --refresh > /dev/null')
380
381     if rev2:
382         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
383     else:
384         diff_str = _output(['git-diff-cache', '-p', rev1] + files)
385
386     if out_fd:
387         out_fd.write(diff_str)
388     else:
389         return diff_str
390
391 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
392     """Return the diffstat between rev1 and rev2
393     """
394
395     os.system('git-update-cache --refresh > /dev/null')
396     p=popen2.Popen3('git-apply --stat')
397     diff(files, rev1, rev2, p.tochild)
398     p.tochild.close()
399     str = p.fromchild.read().rstrip()
400     if p.wait():
401         raise GitException, 'git.diffstat failed'
402     return str
403
404 def files(rev1, rev2):
405     """Return the files modified between rev1 and rev2
406     """
407     os.system('git-update-cache --refresh > /dev/null')
408
409     str = ''
410     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
411         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
412
413     return str.rstrip()
414
415 def checkout(files = [], tree_id = None, force = False):
416     """Check out the given or all files
417     """
418     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
419         raise GitException, 'Failed git-read-tree -m %s' % tree_id
420
421     checkout_cmd = 'git-checkout-cache -q -u'
422     if force:
423         checkout_cmd += ' -f'
424     if len(files) == 0:
425         checkout_cmd += ' -a'
426     else:
427         checkout_cmd += ' --'
428
429     if __run(checkout_cmd, files) != 0:
430         raise GitException, 'Failed git-checkout-cache'
431
432 def switch(tree_id):
433     """Switch the tree to the given id
434     """
435     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
436         raise GitException, 'git-read-tree failed (local changes maybe?)'
437
438     __set_head(tree_id)
439
440 def reset(tree_id = None):
441     """Revert the tree changes relative to the given tree_id. It removes
442     any local changes
443     """
444     if not tree_id:
445         tree_id = get_head()
446
447     cache_files = __tree_status(tree_id = tree_id)
448     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
449
450     checkout(tree_id = tree_id, force = True)
451     __set_head(tree_id)
452
453     # checkout doesn't remove files
454     map(os.remove, rm_files)
455
456 def pull(location, head = None, tag = None):
457     """Fetch changes from the remote repository. At the moment, just
458     use the 'git fetch' scripts
459     """
460     args = [location]
461     if head:
462         args += [head]
463     elif tag:
464         args += ['tag', tag]
465
466     if __run('git pull', args) != 0:
467         raise GitException, 'Failed "git fetch %s"' % location
468
469 def apply_patch(filename = None):
470     """Apply a patch onto the current index. There must not be any
471     local changes in the tree, otherwise the command fails
472     """
473     os.system('git-update-cache --refresh > /dev/null')
474
475     if filename:
476         if __run('git-apply --index', [filename]) != 0:
477             raise GitException, 'Patch does not apply cleanly'
478     else:
479         _input('git-apply --index', sys.stdin)
480
481 def clone(repository, local_dir):
482     """Clone a remote repository. At the moment, just use the
483     'git clone' script
484     """
485     if __run('git clone', [repository, local_dir]) != 0:
486         raise GitException, 'Failed "git clone %s %s"' \
487               % (repository, local_dir)