Fix a bug in "stg branch --rename"
[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+1:])
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 global __commits
96
97 if id_hash in __commits:
98 return __commits[id_hash]
99 else:
100 commit = Commit(id_hash)
101 __commits[id_hash] = commit
102 return commit
103
104 def get_conflicts():
105 """Return the list of file conflicts
106 """
107 conflicts_file = os.path.join(base_dir, 'conflicts')
108 if os.path.isfile(conflicts_file):
109 f = file(conflicts_file)
110 names = [line.strip() for line in f.readlines()]
111 f.close()
112 return names
113 else:
114 return None
115
116 def _input(cmd, file_desc):
117 p = popen2.Popen3(cmd)
118 while True:
119 line = file_desc.readline()
120 if not line:
121 break
122 p.tochild.write(line)
123 p.tochild.close()
124 if p.wait():
125 raise GitException, '%s failed' % str(cmd)
126
127 def _output(cmd):
128 p=popen2.Popen3(cmd)
129 string = p.fromchild.read()
130 if p.wait():
131 raise GitException, '%s failed' % str(cmd)
132 return string
133
134 def _output_one_line(cmd, file_desc = None):
135 p=popen2.Popen3(cmd)
136 if file_desc != None:
137 for line in file_desc:
138 p.tochild.write(line)
139 p.tochild.close()
140 string = p.fromchild.readline().strip()
141 if p.wait():
142 raise GitException, '%s failed' % str(cmd)
143 return string
144
145 def _output_lines(cmd):
146 p=popen2.Popen3(cmd)
147 lines = p.fromchild.readlines()
148 if p.wait():
149 raise GitException, '%s failed' % str(cmd)
150 return lines
151
152 def __run(cmd, args=None):
153 """__run: runs cmd using spawnvp.
154
155 Runs cmd using spawnvp. The shell is avoided so it won't mess up
156 our arguments. If args is very large, the command is run multiple
157 times; args is split xargs style: cmd is passed on each
158 invocation. Unlike xargs, returns immediately if any non-zero
159 return code is received.
160 """
161
162 args_l=cmd.split()
163 if args is None:
164 args = []
165 for i in range(0, len(args)+1, 100):
166 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
167 if r:
168 return r
169 return 0
170
171 def __check_base_dir():
172 return os.path.isdir(base_dir)
173
174 def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
175 noexclude = True):
176 """Returns a list of pairs - [status, filename]
177 """
178 os.system('git-update-index --refresh > /dev/null')
179
180 cache_files = []
181
182 # unknown files
183 if unknown:
184 exclude_file = os.path.join(base_dir, 'info', 'exclude')
185 base_exclude = ['--exclude=%s' % s for s in
186 ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
187 base_exclude.append('--exclude-per-directory=.gitignore')
188
189 if os.path.exists(exclude_file):
190 extra_exclude = ['--exclude-from=%s' % exclude_file]
191 else:
192 extra_exclude = []
193 if noexclude:
194 extra_exclude = base_exclude = []
195
196 lines = _output_lines(['git-ls-files', '--others'] + base_exclude
197 + extra_exclude)
198 cache_files += [('?', line.strip()) for line in lines]
199
200 # conflicted files
201 conflicts = get_conflicts()
202 if not conflicts:
203 conflicts = []
204 cache_files += [('C', filename) for filename in conflicts]
205
206 # the rest
207 for line in _output_lines(['git-diff-index', '-r', tree_id] + files):
208 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
209 if fs[1] not in conflicts:
210 cache_files.append(fs)
211
212 return cache_files
213
214 def local_changes():
215 """Return true if there are local changes in the tree
216 """
217 return len(__tree_status()) != 0
218
219 # HEAD value cached
220 __head = None
221
222 def get_head():
223 """Verifies the HEAD and returns the SHA1 id that represents it
224 """
225 global __head
226
227 if not __head:
228 __head = rev_parse('HEAD')
229 return __head
230
231 def get_head_file():
232 """Returns the name of the file pointed to by the HEAD link
233 """
234 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
235
236 def set_head_file(ref):
237 """Resets HEAD to point to a new ref
238 """
239 if __run('git-symbolic-ref HEAD', [ref]) != 0:
240 raise GitException, 'Could not set head to "%s"' % ref
241
242 def __set_head(val):
243 """Sets the HEAD value
244 """
245 global __head
246
247 if not __head or __head != val:
248 if __run('git-update-ref HEAD', [val]) != 0:
249 raise GitException, 'Could not update HEAD to "%s".' % val
250 __head = val
251
252 def __clear_head_cache():
253 """Sets the __head to None so that a re-read is forced
254 """
255 global __head
256
257 __head = None
258
259 def rev_parse(git_id):
260 """Parse the string and return a verified SHA1 id
261 """
262 try:
263 return _output_one_line(['git-rev-parse', '--verify', git_id])
264 except GitException:
265 raise GitException, 'Unknown revision: %s' % git_id
266
267 def branch_exists(branch):
268 """Existance check for the named branch
269 """
270 for line in _output_lines(['git-rev-parse', '--symbolic', '--all']):
271 if line.strip() == branch:
272 return True
273 return False
274
275 def create_branch(new_branch, tree_id = None):
276 """Create a new branch in the git repository
277 """
278 new_head = os.path.join('refs', 'heads', new_branch)
279 if branch_exists(new_head):
280 raise GitException, 'Branch "%s" already exists' % new_branch
281
282 current_head = get_head()
283 set_head_file(new_head)
284 __set_head(current_head)
285
286 # a checkout isn't needed if new branch points to the current head
287 if tree_id:
288 switch(tree_id)
289
290 if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
291 os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
292
293 def switch_branch(name):
294 """Switch to a git branch
295 """
296 new_head = os.path.join('refs', 'heads', name)
297 if not branch_exists(new_head):
298 raise GitException, 'Branch "%s" does not exist' % name
299
300 tree_id = rev_parse(new_head + '^0')
301 if tree_id != get_head():
302 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
303 raise GitException, 'git-read-tree failed (local changes maybe?)'
304 __head = tree_id
305 set_head_file(new_head)
306
307 if os.path.isfile(os.path.join(base_dir, 'MERGE_HEAD')):
308 os.remove(os.path.join(base_dir, 'MERGE_HEAD'))
309
310 def delete_branch(name):
311 """Delete a git branch
312 """
313 branch_head = os.path.join('refs', 'heads', name)
314 if not branch_exists(branch_head):
315 raise GitException, 'Branch "%s" does not exist' % name
316 os.remove(os.path.join(base_dir, branch_head))
317
318 def rename_branch(from_name, to_name):
319 """Rename a git branch
320 """
321 from_head = os.path.join('refs', 'heads', from_name)
322 if not branch_exists(from_head):
323 raise GitException, 'Branch "%s" does not exist' % from_name
324 to_head = os.path.join('refs', 'heads', to_name)
325 if branch_exists(to_head):
326 raise GitException, 'Branch "%s" already exists' % to_name
327
328 if get_head_file() == from_name:
329 set_head_file(to_head)
330 os.rename(os.path.join(base_dir, from_head), os.path.join(base_dir, to_head))
331
332 def add(names):
333 """Add the files or recursively add the directory contents
334 """
335 # generate the file list
336 files = []
337 for i in names:
338 if not os.path.exists(i):
339 raise GitException, 'Unknown file or directory: %s' % i
340
341 if os.path.isdir(i):
342 # recursive search. We only add files
343 for root, dirs, local_files in os.walk(i):
344 for name in [os.path.join(root, f) for f in local_files]:
345 if os.path.isfile(name):
346 files.append(os.path.normpath(name))
347 elif os.path.isfile(i):
348 files.append(os.path.normpath(i))
349 else:
350 raise GitException, '%s is not a file or directory' % i
351
352 if files:
353 if __run('git-update-index --add --', files):
354 raise GitException, 'Unable to add file'
355
356 def rm(files, force = False):
357 """Remove a file from the repository
358 """
359 if force:
360 git_opt = '--force-remove'
361 else:
362 git_opt = '--remove'
363
364 if not force:
365 for f in files:
366 if os.path.exists(f):
367 raise GitException, '%s exists. Remove it first' %f
368 if files:
369 __run('git-update-index --remove --', files)
370 else:
371 if files:
372 __run('git-update-index --force-remove --', files)
373
374 def update_cache(files = [], force = False):
375 """Update the cache information for the given files
376 """
377 cache_files = __tree_status(files)
378
379 # everything is up-to-date
380 if len(cache_files) == 0:
381 return False
382
383 # check for unresolved conflicts
384 if not force and [x for x in cache_files
385 if x[0] not in ['M', 'N', 'A', 'D']]:
386 raise GitException, 'Updating cache failed: unresolved conflicts'
387
388 # update the cache
389 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
390 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
391 m_files = [x[1] for x in cache_files if x[0] in ['M']]
392
393 if add_files and __run('git-update-index --add --', add_files) != 0:
394 raise GitException, 'Failed git-update-index --add'
395 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
396 raise GitException, 'Failed git-update-index --rm'
397 if m_files and __run('git-update-index --', m_files) != 0:
398 raise GitException, 'Failed git-update-index'
399
400 return True
401
402 def commit(message, files = [], parents = [], allowempty = False,
403 cache_update = True, tree_id = None,
404 author_name = None, author_email = None, author_date = None,
405 committer_name = None, committer_email = None):
406 """Commit the current tree to repository
407 """
408 # Get the tree status
409 if cache_update and parents != []:
410 changes = update_cache(files)
411 if not changes and not allowempty:
412 raise GitException, 'No changes to commit'
413
414 # get the commit message
415 if message[-1:] != '\n':
416 message += '\n'
417
418 must_switch = True
419 # write the index to repository
420 if tree_id == None:
421 tree_id = _output_one_line('git-write-tree')
422 else:
423 must_switch = False
424
425 # the commit
426 cmd = ''
427 if author_name:
428 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
429 if author_email:
430 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
431 if author_date:
432 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
433 if committer_name:
434 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
435 if committer_email:
436 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
437 cmd += 'git-commit-tree %s' % tree_id
438
439 # get the parents
440 for p in parents:
441 cmd += ' -p %s' % p
442
443 commit_id = _output_one_line(cmd, message)
444 if must_switch:
445 __set_head(commit_id)
446
447 return commit_id
448
449 def apply_diff(rev1, rev2):
450 """Apply the diff between rev1 and rev2 onto the current
451 index. This function doesn't need to raise an exception since it
452 is only used for fast-pushing a patch. If this operation fails,
453 the pushing would fall back to the three-way merge.
454 """
455 return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
456 % (rev1, rev2)) == 0
457
458 def merge(base, head1, head2):
459 """Perform a 3-way merge between base, head1 and head2 into the
460 local tree
461 """
462 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
463 raise GitException, 'git-read-tree failed (local changes maybe?)'
464
465 # this can fail if there are conflicts
466 if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
467 raise GitException, 'git-merge-cache failed (possible conflicts)'
468
469 def status(files = [], modified = False, new = False, deleted = False,
470 conflict = False, unknown = False, noexclude = False):
471 """Show the tree status
472 """
473 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
474 all = not (modified or new or deleted or conflict or unknown)
475
476 if not all:
477 filestat = []
478 if modified:
479 filestat.append('M')
480 if new:
481 filestat.append('A')
482 filestat.append('N')
483 if deleted:
484 filestat.append('D')
485 if conflict:
486 filestat.append('C')
487 if unknown:
488 filestat.append('?')
489 cache_files = [x for x in cache_files if x[0] in filestat]
490
491 for fs in cache_files:
492 if all:
493 print '%s %s' % (fs[0], fs[1])
494 else:
495 print '%s' % fs[1]
496
497 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
498 """Show the diff between rev1 and rev2
499 """
500
501 if rev2:
502 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
503 else:
504 os.system('git-update-index --refresh > /dev/null')
505 diff_str = _output(['git-diff-index', '-p', rev1] + files)
506
507 if out_fd:
508 out_fd.write(diff_str)
509 else:
510 return diff_str
511
512 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
513 """Return the diffstat between rev1 and rev2
514 """
515
516 p=popen2.Popen3('git-apply --stat')
517 diff(files, rev1, rev2, p.tochild)
518 p.tochild.close()
519 str = p.fromchild.read().rstrip()
520 if p.wait():
521 raise GitException, 'git.diffstat failed'
522 return str
523
524 def files(rev1, rev2):
525 """Return the files modified between rev1 and rev2
526 """
527
528 str = ''
529 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
530 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
531
532 return str.rstrip()
533
534 def barefiles(rev1, rev2):
535 """Return the files modified between rev1 and rev2, without status info
536 """
537
538 str = ''
539 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
540 str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
541
542 return str.rstrip()
543
544 def checkout(files = [], tree_id = None, force = False):
545 """Check out the given or all files
546 """
547 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
548 raise GitException, 'Failed git-read-tree -m %s' % tree_id
549
550 checkout_cmd = 'git-checkout-index -q -u'
551 if force:
552 checkout_cmd += ' -f'
553 if len(files) == 0:
554 checkout_cmd += ' -a'
555 else:
556 checkout_cmd += ' --'
557
558 if __run(checkout_cmd, files) != 0:
559 raise GitException, 'Failed git-checkout-index'
560
561 def switch(tree_id):
562 """Switch the tree to the given id
563 """
564 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
565 raise GitException, 'git-read-tree failed (local changes maybe?)'
566
567 __set_head(tree_id)
568
569 def reset(tree_id = None):
570 """Revert the tree changes relative to the given tree_id. It removes
571 any local changes
572 """
573 if not tree_id:
574 tree_id = get_head()
575
576 cache_files = __tree_status(tree_id = tree_id)
577 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
578
579 checkout(tree_id = tree_id, force = True)
580 __set_head(tree_id)
581
582 # checkout doesn't remove files
583 map(os.remove, rm_files)
584
585 def pull(repository = 'origin', refspec = None):
586 """Pull changes from the remote repository. At the moment, just
587 use the 'git pull' command
588 """
589 # 'git pull' updates the HEAD
590 __clear_head_cache()
591
592 args = [repository]
593 if refspec:
594 args.append(refspec)
595
596 if __run('git pull', args) != 0:
597 raise GitException, 'Failed "git pull %s"' % repository
598
599 def apply_patch(filename = None, base = None):
600 """Apply a patch onto the current or given index. There must not
601 be any local changes in the tree, otherwise the command fails
602 """
603 def __apply_patch():
604 if filename:
605 return __run('git-apply --index', [filename]) == 0
606 else:
607 try:
608 _input('git-apply --index', sys.stdin)
609 except GitException:
610 return False
611 return True
612
613 os.system('git-update-index --refresh > /dev/null')
614
615 if base:
616 orig_head = get_head()
617 switch(base)
618
619 if not __apply_patch():
620 if base:
621 switch(orig_head)
622 raise GitException, 'Patch does not apply cleanly'
623 elif base:
624 top = commit(message = 'temporary commit used for applying a patch',
625 parents = [base])
626 switch(orig_head)
627 merge(base, orig_head, top)
628
629 def clone(repository, local_dir):
630 """Clone a remote repository. At the moment, just use the
631 'git clone' script
632 """
633 if __run('git clone', [repository, local_dir]) != 0:
634 raise GitException, 'Failed "git clone %s %s"' \
635 % (repository, local_dir)