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