chiark / gitweb /
Cache the base_dir value for subsequent calls
[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, popen2
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27     pass
28
29
30
31 #
32 # Classes
33 #
34 class Commit:
35     """Handle the commit objects
36     """
37     def __init__(self, id_hash):
38         self.__id_hash = id_hash
39
40         lines = _output_lines('git-cat-file commit %s' % id_hash)
41         self.__parents = []
42         for i in range(len(lines)):
43             line = lines[i]
44             if line == '\n':
45                 break
46             field = line.strip().split(' ', 1)
47             if field[0] == 'tree':
48                 self.__tree = field[1]
49             elif field[0] == 'parent':
50                 self.__parents.append(field[1])
51             if field[0] == 'author':
52                 self.__author = field[1]
53             if field[0] == 'committer':
54                 self.__committer = field[1]
55         self.__log = ''.join(lines[i+1:])
56
57     def get_id_hash(self):
58         return self.__id_hash
59
60     def get_tree(self):
61         return self.__tree
62
63     def get_parent(self):
64         return self.__parents[0]
65
66     def get_parents(self):
67         return self.__parents
68
69     def get_author(self):
70         return self.__author
71
72     def get_committer(self):
73         return self.__committer
74
75     def get_log(self):
76         return self.__log
77
78 # dictionary of Commit objects, used to avoid multiple calls to git
79 __commits = dict()
80
81 #
82 # Functions
83 #
84
85 # GIT_DIR value cached
86 __base_dir = None
87
88 def get_base_dir():
89     """Different start-up variables read from the environment
90     """
91     global __base_dir
92
93     if not __base_dir:
94         if 'GIT_DIR' in os.environ:
95             __base_dir = os.environ['GIT_DIR']
96         else:
97             __base_dir = _output_one_line('git-rev-parse --git-dir')
98
99     return __base_dir
100
101 def get_commit(id_hash):
102     """Commit objects factory. Save/look-up them in the __commits
103     dictionary
104     """
105     global __commits
106
107     if id_hash in __commits:
108         return __commits[id_hash]
109     else:
110         commit = Commit(id_hash)
111         __commits[id_hash] = commit
112         return commit
113
114 def get_conflicts():
115     """Return the list of file conflicts
116     """
117     conflicts_file = os.path.join(get_base_dir(), 'conflicts')
118     if os.path.isfile(conflicts_file):
119         f = file(conflicts_file)
120         names = [line.strip() for line in f.readlines()]
121         f.close()
122         return names
123     else:
124         return None
125
126 def _input(cmd, file_desc):
127     p = popen2.Popen3(cmd, True)
128     while True:
129         line = file_desc.readline()
130         if not line:
131             break
132         p.tochild.write(line)
133     p.tochild.close()
134     if p.wait():
135         raise GitException, '%s failed' % str(cmd)
136
137 def _output(cmd):
138     p=popen2.Popen3(cmd, True)
139     output = p.fromchild.read()
140     if p.wait():
141         raise GitException, '%s failed' % str(cmd)
142     return output
143
144 def _output_one_line(cmd, file_desc = None):
145     p=popen2.Popen3(cmd, True)
146     if file_desc != None:
147         for line in file_desc:
148             p.tochild.write(line)
149         p.tochild.close()
150     output = p.fromchild.readline().strip()
151     if p.wait():
152         raise GitException, '%s failed' % str(cmd)
153     return output
154
155 def _output_lines(cmd):
156     p=popen2.Popen3(cmd, True)
157     lines = p.fromchild.readlines()
158     if p.wait():
159         raise GitException, '%s failed' % str(cmd)
160     return lines
161
162 def __run(cmd, args=None):
163     """__run: runs cmd using spawnvp.
164
165     Runs cmd using spawnvp.  The shell is avoided so it won't mess up
166     our arguments.  If args is very large, the command is run multiple
167     times; args is split xargs style: cmd is passed on each
168     invocation.  Unlike xargs, returns immediately if any non-zero
169     return code is received.  
170     """
171     
172     args_l=cmd.split()
173     if args is None:
174         args = []
175     for i in range(0, len(args)+1, 100):
176         r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
177     if r:
178         return r
179     return 0
180
181 def __tree_status(files = None, tree_id = 'HEAD', unknown = False,
182                   noexclude = True):
183     """Returns a list of pairs - [status, filename]
184     """
185     refresh_index()
186
187     if not files:
188         files = []
189     cache_files = []
190
191     # unknown files
192     if unknown:
193         exclude_file = os.path.join(get_base_dir(), 'info', 'exclude')
194         base_exclude = ['--exclude=%s' % s for s in
195                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
196         base_exclude.append('--exclude-per-directory=.gitignore')
197
198         if os.path.exists(exclude_file):
199             extra_exclude = ['--exclude-from=%s' % exclude_file]
200         else:
201             extra_exclude = []
202         if noexclude:
203             extra_exclude = base_exclude = []
204
205         lines = _output_lines(['git-ls-files', '--others'] + base_exclude
206                         + extra_exclude)
207         cache_files += [('?', line.strip()) for line in lines]
208
209     # conflicted files
210     conflicts = get_conflicts()
211     if not conflicts:
212         conflicts = []
213     cache_files += [('C', filename) for filename in conflicts]
214
215     # the rest
216     for line in _output_lines(['git-diff-index', tree_id] + files):
217         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
218         if fs[1] not in conflicts:
219             cache_files.append(fs)
220
221     return cache_files
222
223 def local_changes():
224     """Return true if there are local changes in the tree
225     """
226     return len(__tree_status()) != 0
227
228 # HEAD value cached
229 __head = None
230
231 def get_head():
232     """Verifies the HEAD and returns the SHA1 id that represents it
233     """
234     global __head
235
236     if not __head:
237         __head = rev_parse('HEAD')
238     return __head
239
240 def get_head_file():
241     """Returns the name of the file pointed to by the HEAD link
242     """
243     return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
244
245 def set_head_file(ref):
246     """Resets HEAD to point to a new ref
247     """
248     # head cache flushing is needed since we might have a different value
249     # in the new head
250     __clear_head_cache()
251     if __run('git-symbolic-ref HEAD', [ref]) != 0:
252         raise GitException, 'Could not set head to "%s"' % ref
253
254 def __set_head(val):
255     """Sets the HEAD value
256     """
257     global __head
258
259     if not __head or __head != val:
260         if __run('git-update-ref HEAD', [val]) != 0:
261             raise GitException, 'Could not update HEAD to "%s".' % val
262         __head = val
263
264     # only allow SHA1 hashes
265     assert(len(__head) == 40)
266
267 def __clear_head_cache():
268     """Sets the __head to None so that a re-read is forced
269     """
270     global __head
271
272     __head = None
273
274 def refresh_index():
275     """Refresh index with stat() information from the working directory.
276     """
277     __run('git-update-index -q --unmerged --refresh')
278
279 def rev_parse(git_id):
280     """Parse the string and return a verified SHA1 id
281     """
282     try:
283         return _output_one_line(['git-rev-parse', '--verify', git_id])
284     except GitException:
285         raise GitException, 'Unknown revision: %s' % git_id
286
287 def branch_exists(branch):
288     """Existence check for the named branch
289     """
290     for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
291         if line.strip() == branch:
292             return True
293     return False
294
295 def create_branch(new_branch, tree_id = None):
296     """Create a new branch in the git repository
297     """
298     new_head = os.path.join('refs', 'heads', new_branch)
299     if branch_exists(new_head):
300         raise GitException, 'Branch "%s" already exists' % new_branch
301
302     current_head = get_head()
303     set_head_file(new_head)
304     __set_head(current_head)
305
306     # a checkout isn't needed if new branch points to the current head
307     if tree_id:
308         switch(tree_id)
309
310     if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
311         os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
312
313 def switch_branch(name):
314     """Switch to a git branch
315     """
316     global __head
317
318     new_head = os.path.join('refs', 'heads', name)
319     if not branch_exists(new_head):
320         raise GitException, 'Branch "%s" does not exist' % name
321
322     tree_id = rev_parse(new_head + '^{commit}')
323     if tree_id != get_head():
324         refresh_index()
325         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
326             raise GitException, 'git-read-tree failed (local changes maybe?)'
327         __head = tree_id
328     set_head_file(new_head)
329
330     if os.path.isfile(os.path.join(get_base_dir(), 'MERGE_HEAD')):
331         os.remove(os.path.join(get_base_dir(), 'MERGE_HEAD'))
332
333 def delete_branch(name):
334     """Delete a git branch
335     """
336     branch_head = os.path.join('refs', 'heads', name)
337     if not branch_exists(branch_head):
338         raise GitException, 'Branch "%s" does not exist' % name
339     os.remove(os.path.join(get_base_dir(), branch_head))
340
341 def rename_branch(from_name, to_name):
342     """Rename a git branch
343     """
344     from_head = os.path.join('refs', 'heads', from_name)
345     if not branch_exists(from_head):
346         raise GitException, 'Branch "%s" does not exist' % from_name
347     to_head = os.path.join('refs', 'heads', to_name)
348     if branch_exists(to_head):
349         raise GitException, 'Branch "%s" already exists' % to_name
350
351     if get_head_file() == from_name:
352         set_head_file(to_head)
353     os.rename(os.path.join(get_base_dir(), from_head), \
354               os.path.join(get_base_dir(), to_head))
355
356 def add(names):
357     """Add the files or recursively add the directory contents
358     """
359     # generate the file list
360     files = []
361     for i in names:
362         if not os.path.exists(i):
363             raise GitException, 'Unknown file or directory: %s' % i
364
365         if os.path.isdir(i):
366             # recursive search. We only add files
367             for root, dirs, local_files in os.walk(i):
368                 for name in [os.path.join(root, f) for f in local_files]:
369                     if os.path.isfile(name):
370                         files.append(os.path.normpath(name))
371         elif os.path.isfile(i):
372             files.append(os.path.normpath(i))
373         else:
374             raise GitException, '%s is not a file or directory' % i
375
376     if files:
377         if __run('git-update-index --add --', files):
378             raise GitException, 'Unable to add file'
379
380 def rm(files, force = False):
381     """Remove a file from the repository
382     """
383     if not force:
384         for f in files:
385             if os.path.exists(f):
386                 raise GitException, '%s exists. Remove it first' %f
387         if files:
388             __run('git-update-index --remove --', files)
389     else:
390         if files:
391             __run('git-update-index --force-remove --', files)
392
393 def update_cache(files = None, force = False):
394     """Update the cache information for the given files
395     """
396     if not files:
397         files = []
398
399     cache_files = __tree_status(files)
400
401     # everything is up-to-date
402     if len(cache_files) == 0:
403         return False
404
405     # check for unresolved conflicts
406     if not force and [x for x in cache_files
407                       if x[0] not in ['M', 'N', 'A', 'D']]:
408         raise GitException, 'Updating cache failed: unresolved conflicts'
409
410     # update the cache
411     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
412     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
413     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
414
415     if add_files and __run('git-update-index --add --', add_files) != 0:
416         raise GitException, 'Failed git-update-index --add'
417     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
418         raise GitException, 'Failed git-update-index --rm'
419     if m_files and __run('git-update-index --', m_files) != 0:
420         raise GitException, 'Failed git-update-index'
421
422     return True
423
424 def commit(message, files = None, parents = None, allowempty = False,
425            cache_update = True, tree_id = None,
426            author_name = None, author_email = None, author_date = None,
427            committer_name = None, committer_email = None):
428     """Commit the current tree to repository
429     """
430     if not files:
431         files = []
432     if not parents:
433         parents = []
434
435     # Get the tree status
436     if cache_update and parents != []:
437         changes = update_cache(files)
438         if not changes and not allowempty:
439             raise GitException, 'No changes to commit'
440
441     # get the commit message
442     if message[-1:] != '\n':
443         message += '\n'
444
445     must_switch = True
446     # write the index to repository
447     if tree_id == None:
448         tree_id = _output_one_line('git-write-tree')
449     else:
450         must_switch = False
451
452     # the commit
453     cmd = ''
454     if author_name:
455         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
456     if author_email:
457         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
458     if author_date:
459         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
460     if committer_name:
461         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
462     if committer_email:
463         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
464     cmd += 'git-commit-tree %s' % tree_id
465
466     # get the parents
467     for p in parents:
468         cmd += ' -p %s' % p
469
470     commit_id = _output_one_line(cmd, message)
471     if must_switch:
472         __set_head(commit_id)
473
474     return commit_id
475
476 def apply_diff(rev1, rev2, check_index = True):
477     """Apply the diff between rev1 and rev2 onto the current
478     index. This function doesn't need to raise an exception since it
479     is only used for fast-pushing a patch. If this operation fails,
480     the pushing would fall back to the three-way merge.
481     """
482     if check_index:
483         index_opt = '--index'
484     else:
485         index_opt = ''
486     cmd = 'git-diff-tree -p %s %s | git-apply %s 2> /dev/null' \
487           % (rev1, rev2, index_opt)
488
489     return os.system(cmd) == 0
490
491 def merge(base, head1, head2):
492     """Perform a 3-way merge between base, head1 and head2 into the
493     local tree
494     """
495     refresh_index()
496     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
497         raise GitException, 'git-read-tree failed (local changes maybe?)'
498
499     # this can fail if there are conflicts
500     if __run('git-merge-index -o -q gitmergeonefile.py -a') != 0:
501         raise GitException, 'git-merge-index failed (possible conflicts)'
502
503 def status(files = None, modified = False, new = False, deleted = False,
504            conflict = False, unknown = False, noexclude = False):
505     """Show the tree status
506     """
507     if not files:
508         files = []
509
510     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
511     all = not (modified or new or deleted or conflict or unknown)
512
513     if not all:
514         filestat = []
515         if modified:
516             filestat.append('M')
517         if new:
518             filestat.append('A')
519             filestat.append('N')
520         if deleted:
521             filestat.append('D')
522         if conflict:
523             filestat.append('C')
524         if unknown:
525             filestat.append('?')
526         cache_files = [x for x in cache_files if x[0] in filestat]
527
528     for fs in cache_files:
529         if all:
530             print '%s %s' % (fs[0], fs[1])
531         else:
532             print '%s' % fs[1]
533
534 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
535     """Show the diff between rev1 and rev2
536     """
537     if not files:
538         files = []
539
540     if rev1 and rev2:
541         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
542     elif rev1 or rev2:
543         refresh_index()
544         if rev2:
545             diff_str = _output(['git-diff-index', '-p', '-R', rev2] + files)
546         else:
547             diff_str = _output(['git-diff-index', '-p', rev1] + files)
548     else:
549         diff_str = ''
550
551     if out_fd:
552         out_fd.write(diff_str)
553     else:
554         return diff_str
555
556 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
557     """Return the diffstat between rev1 and rev2
558     """
559     if not files:
560         files = []
561
562     p=popen2.Popen3('git-apply --stat')
563     diff(files, rev1, rev2, p.tochild)
564     p.tochild.close()
565     diff_str = p.fromchild.read().rstrip()
566     if p.wait():
567         raise GitException, 'git.diffstat failed'
568     return diff_str
569
570 def files(rev1, rev2):
571     """Return the files modified between rev1 and rev2
572     """
573
574     result = ''
575     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
576         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
577
578     return result.rstrip()
579
580 def barefiles(rev1, rev2):
581     """Return the files modified between rev1 and rev2, without status info
582     """
583
584     result = ''
585     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
586         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
587
588     return result.rstrip()
589
590 def checkout(files = None, tree_id = None, force = False):
591     """Check out the given or all files
592     """
593     if not files:
594         files = []
595
596     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
597         raise GitException, 'Failed git-read-tree -m %s' % tree_id
598
599     checkout_cmd = 'git-checkout-index -q -u'
600     if force:
601         checkout_cmd += ' -f'
602     if len(files) == 0:
603         checkout_cmd += ' -a'
604     else:
605         checkout_cmd += ' --'
606
607     if __run(checkout_cmd, files) != 0:
608         raise GitException, 'Failed git-checkout-index'
609
610 def switch(tree_id):
611     """Switch the tree to the given id
612     """
613     refresh_index()
614     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
615         raise GitException, 'git-read-tree failed (local changes maybe?)'
616
617     __set_head(tree_id)
618
619 def reset(files = None, tree_id = None):
620     """Revert the tree changes relative to the given tree_id. It removes
621     any local changes
622     """
623     if not tree_id:
624         tree_id = get_head()
625
626     checkout(files, tree_id, True)
627
628     # if the reset refers to the whole tree, switch the HEAD as well
629     if not files:
630         __set_head(tree_id)
631
632 def pull(repository = 'origin', refspec = None):
633     """Pull changes from the remote repository. At the moment, just
634     use the 'git pull' command
635     """
636     # 'git pull' updates the HEAD
637     __clear_head_cache()
638
639     args = [repository]
640     if refspec:
641         args.append(refspec)
642
643     if __run('git pull', args) != 0:
644         raise GitException, 'Failed "git pull %s"' % repository
645
646 def apply_patch(filename = None, base = None):
647     """Apply a patch onto the current or given index. There must not
648     be any local changes in the tree, otherwise the command fails
649     """
650     def __apply_patch():
651         if filename:
652             return __run('git-apply --index', [filename]) == 0
653         else:
654             try:
655                 _input('git-apply --index', sys.stdin)
656             except GitException:
657                 return False
658             return True
659
660     if base:
661         orig_head = get_head()
662         switch(base)
663     else:
664         refresh_index()         # needed since __apply_patch() doesn't do it
665
666     if not __apply_patch():
667         if base:
668             switch(orig_head)
669         raise GitException, 'Patch does not apply cleanly'
670     elif base:
671         top = commit(message = 'temporary commit used for applying a patch',
672                      parents = [base])
673         switch(orig_head)
674         merge(base, orig_head, top)
675
676 def clone(repository, local_dir):
677     """Clone a remote repository. At the moment, just use the
678     'git clone' script
679     """
680     if __run('git clone', [repository, local_dir]) != 0:
681         raise GitException, 'Failed "git clone %s %s"' \
682               % (repository, local_dir)
683
684 def modifying_revs(files, base_rev):
685     """Return the revisions from the list modifying the given files
686     """
687     cmd = ['git-rev-list', '%s..' % base_rev, '--']
688     revs = [line.strip() for line in _output_lines(cmd + files)]
689
690     return revs