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