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