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