Use "git-rev-parse" to get the HEAD commit
[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 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 """Verifies the HEAD and returns the SHA1 id that represents it
219 """
220 return rev_parse('HEAD')
221
222 def get_head_file():
223 """Returns the name of the file pointed to by the HEAD link
224 """
225 return os.path.basename(_output_one_line('git-symbolic-ref HEAD'))
226
227 def __set_head(val):
228 """Sets the HEAD value
229 """
230 if __run('git-update-ref HEAD', [val]) != 0:
231 raise GitException, 'Could not update HEAD to "%s".' % val
232
233 def rev_parse(git_id):
234 """Parse the string and return a verified SHA1 id
235 """
236 return _output_one_line(['git-rev-parse', '--verify', git_id])
237
238 def add(names):
239 """Add the files or recursively add the directory contents
240 """
241 # generate the file list
242 files = []
243 for i in names:
244 if not os.path.exists(i):
245 raise GitException, 'Unknown file or directory: %s' % i
246
247 if os.path.isdir(i):
248 # recursive search. We only add files
249 for root, dirs, local_files in os.walk(i):
250 for name in [os.path.join(root, f) for f in local_files]:
251 if os.path.isfile(name):
252 files.append(os.path.normpath(name))
253 elif os.path.isfile(i):
254 files.append(os.path.normpath(i))
255 else:
256 raise GitException, '%s is not a file or directory' % i
257
258 if files:
259 if __run('git-update-index --add --', files):
260 raise GitException, 'Unable to add file'
261
262 def rm(files, force = False):
263 """Remove a file from the repository
264 """
265 if force:
266 git_opt = '--force-remove'
267 else:
268 git_opt = '--remove'
269
270 if not force:
271 for f in files:
272 if os.path.exists(f):
273 raise GitException, '%s exists. Remove it first' %f
274 if files:
275 __run('git-update-index --remove --', files)
276 else:
277 if files:
278 __run('git-update-index --force-remove --', files)
279
280 def update_cache(files = [], force = False):
281 """Update the cache information for the given files
282 """
283 cache_files = __tree_status(files)
284
285 # everything is up-to-date
286 if len(cache_files) == 0:
287 return False
288
289 # check for unresolved conflicts
290 if not force and [x for x in cache_files
291 if x[0] not in ['M', 'N', 'A', 'D']]:
292 raise GitException, 'Updating cache failed: unresolved conflicts'
293
294 # update the cache
295 add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
296 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
297 m_files = [x[1] for x in cache_files if x[0] in ['M']]
298
299 if add_files and __run('git-update-index --add --', add_files) != 0:
300 raise GitException, 'Failed git-update-index --add'
301 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
302 raise GitException, 'Failed git-update-index --rm'
303 if m_files and __run('git-update-index --', m_files) != 0:
304 raise GitException, 'Failed git-update-index'
305
306 return True
307
308 def commit(message, files = [], parents = [], allowempty = False,
309 cache_update = True, tree_id = None,
310 author_name = None, author_email = None, author_date = None,
311 committer_name = None, committer_email = None):
312 """Commit the current tree to repository
313 """
314 # Get the tree status
315 if cache_update and parents != []:
316 changes = update_cache(files)
317 if not changes and not allowempty:
318 raise GitException, 'No changes to commit'
319
320 # get the commit message
321 if message[-1:] != '\n':
322 message += '\n'
323
324 must_switch = True
325 # write the index to repository
326 if tree_id == None:
327 tree_id = _output_one_line('git-write-tree')
328 else:
329 must_switch = False
330
331 # the commit
332 cmd = ''
333 if author_name:
334 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
335 if author_email:
336 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
337 if author_date:
338 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
339 if committer_name:
340 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
341 if committer_email:
342 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
343 cmd += 'git-commit-tree %s' % tree_id
344
345 # get the parents
346 for p in parents:
347 cmd += ' -p %s' % p
348
349 commit_id = _output_one_line(cmd, message)
350 if must_switch:
351 __set_head(commit_id)
352
353 return commit_id
354
355 def apply_diff(rev1, rev2):
356 """Apply the diff between rev1 and rev2 onto the current
357 index. This function doesn't need to raise an exception since it
358 is only used for fast-pushing a patch. If this operation fails,
359 the pushing would fall back to the three-way merge.
360 """
361 return os.system('git-diff-tree -p %s %s | git-apply --index 2> /dev/null'
362 % (rev1, rev2)) == 0
363
364 def merge(base, head1, head2):
365 """Perform a 3-way merge between base, head1 and head2 into the
366 local tree
367 """
368 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
369 raise GitException, 'git-read-tree failed (local changes maybe?)'
370
371 # this can fail if there are conflicts
372 if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
373 raise GitException, 'git-merge-cache failed (possible conflicts)'
374
375 def status(files = [], modified = False, new = False, deleted = False,
376 conflict = False, unknown = False, noexclude = False):
377 """Show the tree status
378 """
379 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
380 all = not (modified or new or deleted or conflict or unknown)
381
382 if not all:
383 filestat = []
384 if modified:
385 filestat.append('M')
386 if new:
387 filestat.append('A')
388 filestat.append('N')
389 if deleted:
390 filestat.append('D')
391 if conflict:
392 filestat.append('C')
393 if unknown:
394 filestat.append('?')
395 cache_files = [x for x in cache_files if x[0] in filestat]
396
397 for fs in cache_files:
398 if all:
399 print '%s %s' % (fs[0], fs[1])
400 else:
401 print '%s' % fs[1]
402
403 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
404 """Show the diff between rev1 and rev2
405 """
406
407 if rev2:
408 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
409 else:
410 os.system('git-update-index --refresh > /dev/null')
411 diff_str = _output(['git-diff-index', '-p', rev1] + files)
412
413 if out_fd:
414 out_fd.write(diff_str)
415 else:
416 return diff_str
417
418 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
419 """Return the diffstat between rev1 and rev2
420 """
421
422 p=popen2.Popen3('git-apply --stat')
423 diff(files, rev1, rev2, p.tochild)
424 p.tochild.close()
425 str = p.fromchild.read().rstrip()
426 if p.wait():
427 raise GitException, 'git.diffstat failed'
428 return str
429
430 def files(rev1, rev2):
431 """Return the files modified between rev1 and rev2
432 """
433
434 str = ''
435 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
436 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
437
438 return str.rstrip()
439
440 def barefiles(rev1, rev2):
441 """Return the files modified between rev1 and rev2, without status info
442 """
443
444 str = ''
445 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
446 str += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
447
448 return str.rstrip()
449
450 def checkout(files = [], tree_id = None, force = False):
451 """Check out the given or all files
452 """
453 if tree_id and __run('git-read-tree -m', [tree_id]) != 0:
454 raise GitException, 'Failed git-read-tree -m %s' % tree_id
455
456 checkout_cmd = 'git-checkout-index -q -u'
457 if force:
458 checkout_cmd += ' -f'
459 if len(files) == 0:
460 checkout_cmd += ' -a'
461 else:
462 checkout_cmd += ' --'
463
464 if __run(checkout_cmd, files) != 0:
465 raise GitException, 'Failed git-checkout-index'
466
467 def switch(tree_id):
468 """Switch the tree to the given id
469 """
470 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
471 raise GitException, 'git-read-tree failed (local changes maybe?)'
472
473 __set_head(tree_id)
474
475 def reset(tree_id = None):
476 """Revert the tree changes relative to the given tree_id. It removes
477 any local changes
478 """
479 if not tree_id:
480 tree_id = get_head()
481
482 cache_files = __tree_status(tree_id = tree_id)
483 rm_files = [x[1] for x in cache_files if x[0] in ['D']]
484
485 checkout(tree_id = tree_id, force = True)
486 __set_head(tree_id)
487
488 # checkout doesn't remove files
489 map(os.remove, rm_files)
490
491 def pull(repository = 'origin', refspec = None):
492 """Pull changes from the remote repository. At the moment, just
493 use the 'git pull' command
494 """
495 args = [repository]
496 if refspec:
497 args.append(refspec)
498
499 if __run('git pull', args) != 0:
500 raise GitException, 'Failed "git pull %s"' % repository
501
502 def apply_patch(filename = None):
503 """Apply a patch onto the current index. There must not be any
504 local changes in the tree, otherwise the command fails
505 """
506 os.system('git-update-index --refresh > /dev/null')
507
508 if filename:
509 if __run('git-apply --index', [filename]) != 0:
510 raise GitException, 'Patch does not apply cleanly'
511 else:
512 _input('git-apply --index', sys.stdin)
513
514 def clone(repository, local_dir):
515 """Clone a remote repository. At the moment, just use the
516 'git clone' script
517 """
518 if __run('git clone', [repository, local_dir]) != 0:
519 raise GitException, 'Failed "git clone %s %s"' \
520 % (repository, local_dir)