Pass -q to git-merge-cache
[stgit] / stgit / git.py
CommitLineData
41a6d859
CM
1"""Python GIT interface
2"""
3
4__copyright__ = """
5Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7This program is free software; you can redistribute it and/or modify
8it under the terms of the GNU General Public License version 2 as
9published by the Free Software Foundation.
10
11This program is distributed in the hope that it will be useful,
12but WITHOUT ANY WARRANTY; without even the implied warranty of
13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14GNU General Public License for more details.
15
16You should have received a copy of the GNU General Public License
17along with this program; if not, write to the Free Software
18Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19"""
20
26dba451 21import sys, os, glob, popen2
41a6d859
CM
22
23from stgit.utils import *
24
25# git exception class
26class GitException(Exception):
27 pass
28
29
30# Different start-up variables read from the environment
31if 'GIT_DIR' in os.environ:
32 base_dir = os.environ['GIT_DIR']
33else:
34 base_dir = '.git'
35
36head_link = os.path.join(base_dir, 'HEAD')
37
41a6d859
CM
38#
39# Classes
40#
41class Commit:
42 """Handle the commit objects
43 """
44 def __init__(self, id_hash):
45 self.__id_hash = id_hash
41a6d859 46
26dba451
BL
47 lines = _output_lines('git-cat-file commit %s' % id_hash)
48 for i in range(len(lines)):
49 line = lines[i]
41a6d859
CM
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]
26dba451 61 self.__log = ''.join(lines[i:])
41a6d859
CM
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
8e29bcd2
CM
78# dictionary of Commit objects, used to avoid multiple calls to git
79__commits = dict()
41a6d859
CM
80
81#
82# Functions
83#
8e29bcd2
CM
84def 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
41a6d859
CM
95def 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
0d2cd1e4
CM
107def _input(cmd, file_desc):
108 p = popen2.Popen3(cmd)
109 for line in file_desc:
0d2cd1e4
CM
110 p.tochild.write(line)
111 p.tochild.close()
112 if p.wait():
113 raise GitException, '%s failed' % str(cmd)
114
26dba451
BL
115def _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
122def _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)
41a6d859
CM
127 return string
128
26dba451
BL
129def _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
136def __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
41a6d859
CM
155def __check_base_dir():
156 return os.path.isdir(base_dir)
157
be24d874
CM
158def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
159 noexclude = True):
41a6d859
CM
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:
be24d874
CM
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
41a6d859 173 if os.path.exists(exclude_file):
be24d874
CM
174 extra_exclude = '--exclude-from=%s' % exclude_file
175 else:
176 extra_exclude = []
4d4c0e3a
PBG
177 if noexclude:
178 extra_exclude = base_exclude = []
be24d874
CM
179
180 lines = _output_lines(['git-ls-files', '--others'] + base_exclude
4d4c0e3a 181 + extra_exclude)
26dba451 182 cache_files += [('?', line.strip()) for line in lines]
41a6d859
CM
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
26dba451
BL
191 for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
192 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
41a6d859
CM
193 if fs[1] not in conflicts:
194 cache_files.append(fs)
41a6d859
CM
195
196 return cache_files
197
198def local_changes():
199 """Return true if there are local changes in the tree
200 """
201 return len(__tree_status()) != 0
202
203def get_head():
204 """Returns a string representing the HEAD
205 """
206 return read_string(head_link)
207
208def 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
217def __set_head(val):
218 """Sets the HEAD value
219 """
220 write_string(head_link, val)
221
222def 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
26dba451
BL
242 if files:
243 if __run('git-update-cache --add --', files):
244 raise GitException, 'Unable to add file'
41a6d859
CM
245
246def 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
26dba451
BL
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)
41a6d859 263
402ad990 264def update_cache(files = [], force = False):
cfafb945
CM
265 """Update the cache information for the given files
266 """
402ad990 267 cache_files = __tree_status(files)
26dba451 268
402ad990
CM
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'
26dba451 277
402ad990
CM
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
cfafb945 291
41a6d859 292def commit(message, files = [], parents = [], allowempty = False,
402ad990 293 cache_update = True,
41a6d859
CM
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 """
41a6d859 298 # Get the tree status
402ad990
CM
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'
41a6d859
CM
303
304 # get the commit message
305 f = file('.commitmsg', 'w+')
011cbd1b 306 if message[-1:] == '\n':
41a6d859
CM
307 f.write(message)
308 else:
309 print >> f, message
310 f.close()
311
41a6d859 312 # write the index to repository
26dba451 313 tree_id = _output_one_line('git-write-tree')
41a6d859
CM
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
26dba451 335 commit_id = _output_one_line(cmd)
41a6d859
CM
336 __set_head(commit_id)
337 os.remove('.commitmsg')
338
339 return commit_id
340
341def merge(base, head1, head2):
342 """Perform a 3-way merge between base, head1 and head2 into the
343 local tree
344 """
26dba451 345 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
41a6d859
CM
346 raise GitException, 'git-read-tree failed (local changes maybe?)'
347
348 # this can fail if there are conflicts
101239d5 349 if os.system('git-merge-cache -o -q gitmergeonefile.py -a') != 0:
41a6d859
CM
350 raise GitException, 'git-merge-cache failed (possible conflicts)'
351
41a6d859 352def status(files = [], modified = False, new = False, deleted = False,
4d4c0e3a 353 conflict = False, unknown = False, noexclude = False):
41a6d859
CM
354 """Show the tree status
355 """
4d4c0e3a 356 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
41a6d859
CM
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:
7371951a 364 filestat.append('A')
41a6d859
CM
365 filestat.append('N')
366 if deleted:
367 filestat.append('D')
368 if conflict:
369 filestat.append('C')
370 if unknown:
371 filestat.append('?')
402ad990 372 cache_files = [x for x in cache_files if x[0] in filestat]
41a6d859
CM
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
b4bddc06 380def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
41a6d859
CM
381 """Show the diff between rev1 and rev2
382 """
41a6d859
CM
383 os.system('git-update-cache --refresh > /dev/null')
384
385 if rev2:
b4bddc06 386 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
41a6d859 387 else:
b4bddc06
CM
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
41a6d859
CM
394
395def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
396 """Return the diffstat between rev1 and rev2
397 """
41a6d859
CM
398
399 os.system('git-update-cache --refresh > /dev/null')
26dba451
BL
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'
41a6d859
CM
406 return str
407
408def 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 = ''
26dba451
BL
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))
41a6d859
CM
416
417 return str.rstrip()
418
1008fbce 419def checkout(files = [], tree_id = None, force = False):
41a6d859
CM
420 """Check out the given or all files
421 """
1008fbce
CM
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'
41a6d859 426 if force:
1008fbce 427 checkout_cmd += ' -f'
41a6d859 428 if len(files) == 0:
1008fbce 429 checkout_cmd += ' -a'
41a6d859 430 else:
1008fbce 431 checkout_cmd += ' --'
41a6d859 432
1008fbce 433 if __run(checkout_cmd, files) != 0:
26dba451 434 raise GitException, 'Failed git-checkout-cache'
41a6d859
CM
435
436def switch(tree_id):
437 """Switch the tree to the given id
438 """
a5b29a1c
CM
439 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
440 raise GitException, 'git-read-tree failed (local changes maybe?)'
41a6d859 441
41a6d859
CM
442 __set_head(tree_id)
443
05d593c0
CM
444def 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
1f5e9148
CM
460def pull(repository = 'origin', refspec = None):
461 """Pull changes from the remote repository. At the moment, just
462 use the 'git pull' command
f338c3c0 463 """
1f5e9148
CM
464 args = [repository]
465 if refspec:
466 args.append(refspec)
f338c3c0 467
ddbbfd84 468 if __run('git pull', args) != 0:
1f5e9148 469 raise GitException, 'Failed "git pull %s"' % repository
f338c3c0 470
0d2cd1e4
CM
471def 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)
1008fbce
CM
482
483def 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)