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