chiark / gitweb /
Use .git/info/exclude instead of .git/exclude
[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                   noexclude = True):
160     """Returns a list of pairs - [status, filename]
161     """
162     os.system('git-update-cache --refresh > /dev/null')
163
164     cache_files = []
165
166     # unknown files
167     if unknown:
168         exclude_file = os.path.join(base_dir, 'info', 'exclude')
169         base_exclude = ['--exclude=%s' % s for s in
170                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
171         base_exclude.append('--exclude-per-directory=.gitignore')
172
173         if os.path.exists(exclude_file):
174             extra_exclude = '--exclude-from=%s' % exclude_file
175         else:
176             extra_exclude = []
177         if noexclude:
178             extra_exclude = base_exclude = []
179
180         lines = _output_lines(['git-ls-files', '--others'] + base_exclude
181                         + extra_exclude)
182         cache_files += [('?', line.strip()) for line in lines]
183
184     # conflicted files
185     conflicts = get_conflicts()
186     if not conflicts:
187         conflicts = []
188     cache_files += [('C', filename) for filename in conflicts]
189
190     # the rest
191     for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
192         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
193         if fs[1] not in conflicts:
194             cache_files.append(fs)
195
196     return cache_files
197
198 def local_changes():
199     """Return true if there are local changes in the tree
200     """
201     return len(__tree_status()) != 0
202
203 def get_head():
204     """Returns a string representing the HEAD
205     """
206     return read_string(head_link)
207
208 def get_head_file():
209     """Returns the name of the file pointed to by the HEAD link
210     """
211     # valid link
212     if os.path.islink(head_link) and os.path.isfile(head_link):
213         return os.path.basename(os.readlink(head_link))
214     else:
215         raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
216
217 def __set_head(val):
218     """Sets the HEAD value
219     """
220     write_string(head_link, val)
221
222 def add(names):
223     """Add the files or recursively add the directory contents
224     """
225     # generate the file list
226     files = []
227     for i in names:
228         if not os.path.exists(i):
229             raise GitException, 'Unknown file or directory: %s' % i
230
231         if os.path.isdir(i):
232             # recursive search. We only add files
233             for root, dirs, local_files in os.walk(i):
234                 for name in [os.path.join(root, f) for f in local_files]:
235                     if os.path.isfile(name):
236                         files.append(os.path.normpath(name))
237         elif os.path.isfile(i):
238             files.append(os.path.normpath(i))
239         else:
240             raise GitException, '%s is not a file or directory' % i
241
242     if files:
243         if __run('git-update-cache --add --', files):
244             raise GitException, 'Unable to add file'
245
246 def rm(files, force = False):
247     """Remove a file from the repository
248     """
249     if force:
250         git_opt = '--force-remove'
251     else:
252         git_opt = '--remove'
253
254     if not force:
255         for f in files:
256             if os.path.exists(f):
257                 raise GitException, '%s exists. Remove it first' %f
258         if files:
259             __run('git-update-cache --remove --', files)
260     else:
261         if files:
262             __run('git-update-cache --force-remove --', files)
263
264 def update_cache(files = [], force = False):
265     """Update the cache information for the given files
266     """
267     cache_files = __tree_status(files)
268
269     # everything is up-to-date
270     if len(cache_files) == 0:
271         return False
272
273     # check for unresolved conflicts
274     if not force and [x for x in cache_files
275                       if x[0] not in ['M', 'N', 'A', 'D']]:
276         raise GitException, 'Updating cache failed: unresolved conflicts'
277
278     # update the cache
279     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
280     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
281     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
282
283     if add_files and __run('git-update-cache --add --', add_files) != 0:
284         raise GitException, 'Failed git-update-cache --add'
285     if rm_files and __run('git-update-cache --force-remove --', rm_files) != 0:
286         raise GitException, 'Failed git-update-cache --rm'
287     if m_files and __run('git-update-cache --', m_files) != 0:
288         raise GitException, 'Failed git-update-cache'
289
290     return True
291
292 def commit(message, files = [], parents = [], allowempty = False,
293            cache_update = True,
294            author_name = None, author_email = None, author_date = None,
295            committer_name = None, committer_email = None):
296     """Commit the current tree to repository
297     """
298     # Get the tree status
299     if cache_update and parents != []:
300         changes = update_cache(files)
301         if not changes and not allowempty:
302             raise GitException, 'No changes to commit'
303
304     # get the commit message
305     f = file('.commitmsg', 'w+')
306     if message[-1:] == '\n':
307         f.write(message)
308     else:
309         print >> f, message
310     f.close()
311
312     # write the index to repository
313     tree_id = _output_one_line('git-write-tree')
314
315     # the commit
316     cmd = ''
317     if author_name:
318         cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
319     if author_email:
320         cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
321     if author_date:
322         cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
323     if committer_name:
324         cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
325     if committer_email:
326         cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
327     cmd += 'git-commit-tree %s' % tree_id
328
329     # get the parents
330     for p in parents:
331         cmd += ' -p %s' % p
332
333     cmd += ' < .commitmsg'
334
335     commit_id = _output_one_line(cmd)
336     __set_head(commit_id)
337     os.remove('.commitmsg')
338
339     return commit_id
340
341 def merge(base, head1, head2):
342     """Perform a 3-way merge between base, head1 and head2 into the
343     local tree
344     """
345     if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
346         raise GitException, 'git-read-tree failed (local changes maybe?)'
347
348     # this can fail if there are conflicts
349     if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
350         raise GitException, 'git-merge-cache failed (possible conflicts)'
351
352 def status(files = [], modified = False, new = False, deleted = False,
353            conflict = False, unknown = False, noexclude = False):
354     """Show the tree status
355     """
356     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
357     all = not (modified or new or deleted or conflict or unknown)
358
359     if not all:
360         filestat = []
361         if modified:
362             filestat.append('M')
363         if new:
364             filestat.append('A')
365             filestat.append('N')
366         if deleted:
367             filestat.append('D')
368         if conflict:
369             filestat.append('C')
370         if unknown:
371             filestat.append('?')
372         cache_files = [x for x in cache_files if x[0] in filestat]
373
374     for fs in cache_files:
375         if all:
376             print '%s %s' % (fs[0], fs[1])
377         else:
378             print '%s' % fs[1]
379
380 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
381     """Show the diff between rev1 and rev2
382     """
383     os.system('git-update-cache --refresh > /dev/null')
384
385     if rev2:
386         diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
387     else:
388         diff_str = _output(['git-diff-cache', '-p', rev1] + files)
389
390     if out_fd:
391         out_fd.write(diff_str)
392     else:
393         return diff_str
394
395 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
396     """Return the diffstat between rev1 and rev2
397     """
398
399     os.system('git-update-cache --refresh > /dev/null')
400     p=popen2.Popen3('git-apply --stat')
401     diff(files, rev1, rev2, p.tochild)
402     p.tochild.close()
403     str = p.fromchild.read().rstrip()
404     if p.wait():
405         raise GitException, 'git.diffstat failed'
406     return str
407
408 def files(rev1, rev2):
409     """Return the files modified between rev1 and rev2
410     """
411     os.system('git-update-cache --refresh > /dev/null')
412
413     str = ''
414     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
415         str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
416
417     return str.rstrip()
418
419 def checkout(files = [], tree_id = None, force = False):
420     """Check out the given or all files
421     """
422     if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
423         raise GitException, 'Failed git-read-tree -m %s' % tree_id
424
425     checkout_cmd = 'git-checkout-cache -q -u'
426     if force:
427         checkout_cmd += ' -f'
428     if len(files) == 0:
429         checkout_cmd += ' -a'
430     else:
431         checkout_cmd += ' --'
432
433     if __run(checkout_cmd, files) != 0:
434         raise GitException, 'Failed git-checkout-cache'
435
436 def switch(tree_id):
437     """Switch the tree to the given id
438     """
439     if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
440         raise GitException, 'git-read-tree failed (local changes maybe?)'
441
442     __set_head(tree_id)
443
444 def reset(tree_id = None):
445     """Revert the tree changes relative to the given tree_id. It removes
446     any local changes
447     """
448     if not tree_id:
449         tree_id = get_head()
450
451     cache_files = __tree_status(tree_id = tree_id)
452     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
453
454     checkout(tree_id = tree_id, force = True)
455     __set_head(tree_id)
456
457     # checkout doesn't remove files
458     map(os.remove, rm_files)
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)