Optimise the merge operation
[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 = [], force = False):
258 """Update the cache information for the given files
259 """
260 cache_files = __tree_status(files)
261
262 # everything is up-to-date
263 if len(cache_files) == 0:
264 return False
265
266 # check for unresolved conflicts
267 if not force and [x for x in cache_files
268 if x[0] not in ['M', 'N', 'A', 'D']]:
269 raise GitException, 'Updating cache failed: unresolved conflicts'
270
271 # update the cache
272 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
273 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
274 m_files = [x[1] for x in cache_files if x[0] in ['M']]
275
276 if add_files and __run('git-update-cache --add --', add_files) != 0:
277 raise GitException, 'Failed git-update-cache --add'
278 if rm_files and __run('git-update-cache --force-remove --', rm_files) != 0:
279 raise GitException, 'Failed git-update-cache --rm'
280 if m_files and __run('git-update-cache --', m_files) != 0:
281 raise GitException, 'Failed git-update-cache'
282
283 return True
284
285 def commit(message, files = [], parents = [], allowempty = False,
286 cache_update = True,
287 author_name = None, author_email = None, author_date = None,
288 committer_name = None, committer_email = None):
289 """Commit the current tree to repository
290 """
291 # Get the tree status
292 if cache_update and parents != []:
293 changes = update_cache(files)
294 if not changes and not allowempty:
295 raise GitException, 'No changes to commit'
296
297 # get the commit message
298 f = file('.commitmsg', 'w+')
299 if message[-1] == '\n':
300 f.write(message)
301 else:
302 print >> f, message
303 f.close()
304
305 # write the index to repository
306 tree_id = _output_one_line('git-write-tree')
307
308 # the commit
309 cmd = ''
310 if author_name:
311 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
312 if author_email:
313 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
314 if author_date:
315 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
316 if committer_name:
317 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
318 if committer_email:
319 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
320 cmd += 'git-commit-tree %s' % tree_id
321
322 # get the parents
323 for p in parents:
324 cmd += ' -p %s' % p
325
326 cmd += ' < .commitmsg'
327
328 commit_id = _output_one_line(cmd)
329 __set_head(commit_id)
330 os.remove('.commitmsg')
331
332 return commit_id
333
334 def merge(base, head1, head2):
335 """Perform a 3-way merge between base, head1 and head2 into the
336 local tree
337 """
338 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
339 raise GitException, 'git-read-tree failed (local changes maybe?)'
340
341 # this can fail if there are conflicts
342 if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
343 raise GitException, 'git-merge-cache failed (possible conflicts)'
344
345 def status(files = [], modified = False, new = False, deleted = False,
346 conflict = False, unknown = False):
347 """Show the tree status
348 """
349 cache_files = __tree_status(files, unknown = True)
350 all = not (modified or new or deleted or conflict or unknown)
351
352 if not all:
353 filestat = []
354 if modified:
355 filestat.append('M')
356 if new:
357 filestat.append('A')
358 filestat.append('N')
359 if deleted:
360 filestat.append('D')
361 if conflict:
362 filestat.append('C')
363 if unknown:
364 filestat.append('?')
365 cache_files = [x for x in cache_files if x[0] in filestat]
366
367 for fs in cache_files:
368 if all:
369 print '%s %s' % (fs[0], fs[1])
370 else:
371 print '%s' % fs[1]
372
373 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
374 """Show the diff between rev1 and rev2
375 """
376 os.system('git-update-cache --refresh > /dev/null')
377
378 if rev2:
379 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
380 else:
381 diff_str = _output(['git-diff-cache', '-p', rev1] + files)
382
383 if out_fd:
384 out_fd.write(diff_str)
385 else:
386 return diff_str
387
388 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
389 """Return the diffstat between rev1 and rev2
390 """
391
392 os.system('git-update-cache --refresh > /dev/null')
393 p=popen2.Popen3('git-apply --stat')
394 diff(files, rev1, rev2, p.tochild)
395 p.tochild.close()
396 str = p.fromchild.read().rstrip()
397 if p.wait():
398 raise GitException, 'git.diffstat failed'
399 return str
400
401 def files(rev1, rev2):
402 """Return the files modified between rev1 and rev2
403 """
404 os.system('git-update-cache --refresh > /dev/null')
405
406 str = ''
407 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
408 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
409
410 return str.rstrip()
411
412 def checkout(files = [], tree_id = None, force = False):
413 """Check out the given or all files
414 """
415 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
416 raise GitException, 'Failed git-read-tree -m %s' % tree_id
417
418 checkout_cmd = 'git-checkout-cache -q -u'
419 if force:
420 checkout_cmd += ' -f'
421 if len(files) == 0:
422 checkout_cmd += ' -a'
423 else:
424 checkout_cmd += ' --'
425
426 if __run(checkout_cmd, files) != 0:
427 raise GitException, 'Failed git-checkout-cache'
428
429 def switch(tree_id):
430 """Switch the tree to the given id
431 """
432 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
433 raise GitException, 'git-read-tree failed (local changes maybe?)'
434
435 __set_head(tree_id)
436
437 def pull(location, head = None, tag = None):
438 """Fetch changes from the remote repository. At the moment, just
439 use the 'git fetch' scripts
440 """
441 args = [location]
442 if head:
443 args += [head]
444 elif tag:
445 args += ['tag', tag]
446
447 if __run('git pull', args) != 0:
448 raise GitException, 'Failed "git fetch %s"' % location
449
450 def apply_patch(filename = None):
451 """Apply a patch onto the current index. There must not be any
452 local changes in the tree, otherwise the command fails
453 """
454 os.system('git-update-cache --refresh > /dev/null')
455
456 if filename:
457 if __run('git-apply --index', [filename]) != 0:
458 raise GitException, 'Patch does not apply cleanly'
459 else:
460 _input('git-apply --index', sys.stdin)
461
462 def clone(repository, local_dir):
463 """Clone a remote repository. At the moment, just use the
464 'git clone' script
465 """
466 if __run('git clone', [repository, local_dir]) != 0:
467 raise GitException, 'Failed "git clone %s %s"' \
468 % (repository, local_dir)