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