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