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