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