Cut verbosity of new fast-forward merging
[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 47 lines = _output_lines('git-cat-file commit %s' % id_hash)
37a4d1bf 48 self.__parents = []
26dba451
BL
49 for i in range(len(lines)):
50 line = lines[i]
41a6d859
CM
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':
37a4d1bf 57 self.__parents.append(field[1])
41a6d859
CM
58 if field[0] == 'author':
59 self.__author = field[1]
dad310d0 60 if field[0] == 'committer':
41a6d859 61 self.__committer = field[1]
26dba451 62 self.__log = ''.join(lines[i:])
41a6d859
CM
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):
37a4d1bf
CM
71 return self.__parents[0]
72
73 def get_parents(self):
74 return self.__parents
41a6d859
CM
75
76 def get_author(self):
77 return self.__author
78
79 def get_committer(self):
80 return self.__committer
81
37a4d1bf
CM
82 def get_log(self):
83 return self.__log
84
8e29bcd2
CM
85# dictionary of Commit objects, used to avoid multiple calls to git
86__commits = dict()
41a6d859
CM
87
88#
89# Functions
90#
8e29bcd2
CM
91def 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
41a6d859
CM
102def 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
0d2cd1e4
CM
114def _input(cmd, file_desc):
115 p = popen2.Popen3(cmd)
116 for line in file_desc:
0d2cd1e4
CM
117 p.tochild.write(line)
118 p.tochild.close()
119 if p.wait():
120 raise GitException, '%s failed' % str(cmd)
121
26dba451
BL
122def _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
d3cf7d86 129def _output_one_line(cmd, file_desc = None):
26dba451 130 p=popen2.Popen3(cmd)
d3cf7d86
PBG
131 if file_desc != None:
132 for line in file_desc:
133 p.tochild.write(line)
134 p.tochild.close()
26dba451
BL
135 string = p.fromchild.readline().strip()
136 if p.wait():
137 raise GitException, '%s failed' % str(cmd)
41a6d859
CM
138 return string
139
26dba451
BL
140def _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
147def __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
41a6d859
CM
166def __check_base_dir():
167 return os.path.isdir(base_dir)
168
be24d874
CM
169def __tree_status(files = [], tree_id = 'HEAD', unknown = False,
170 noexclude = True):
41a6d859
CM
171 """Returns a list of pairs - [status, filename]
172 """
7c09df84 173 os.system('git-update-index --refresh > /dev/null')
41a6d859
CM
174
175 cache_files = []
176
177 # unknown files
178 if unknown:
be24d874
CM
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
41a6d859 184 if os.path.exists(exclude_file):
3c6fbd2c 185 extra_exclude = ['--exclude-from=%s' % exclude_file]
be24d874
CM
186 else:
187 extra_exclude = []
4d4c0e3a
PBG
188 if noexclude:
189 extra_exclude = base_exclude = []
be24d874
CM
190
191 lines = _output_lines(['git-ls-files', '--others'] + base_exclude
4d4c0e3a 192 + extra_exclude)
26dba451 193 cache_files += [('?', line.strip()) for line in lines]
41a6d859
CM
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
7c09df84 202 for line in _output_lines(['git-diff-index', '-r', tree_id] + files):
26dba451 203 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
41a6d859
CM
204 if fs[1] not in conflicts:
205 cache_files.append(fs)
41a6d859
CM
206
207 return cache_files
208
209def local_changes():
210 """Return true if there are local changes in the tree
211 """
212 return len(__tree_status()) != 0
213
214def get_head():
215 """Returns a string representing the HEAD
216 """
217 return read_string(head_link)
218
219def 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
228def __set_head(val):
229 """Sets the HEAD value
230 """
231 write_string(head_link, val)
232
233def 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
26dba451 253 if files:
7c09df84 254 if __run('git-update-index --add --', files):
26dba451 255 raise GitException, 'Unable to add file'
41a6d859
CM
256
257def 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
26dba451
BL
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:
7c09df84 270 __run('git-update-index --remove --', files)
26dba451
BL
271 else:
272 if files:
7c09df84 273 __run('git-update-index --force-remove --', files)
41a6d859 274
402ad990 275def update_cache(files = [], force = False):
cfafb945
CM
276 """Update the cache information for the given files
277 """
402ad990 278 cache_files = __tree_status(files)
26dba451 279
402ad990
CM
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'
26dba451 288
402ad990
CM
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
7c09df84
JH
294 if add_files and __run('git-update-index --add --', add_files) != 0:
295 raise GitException, 'Failed git-update-index --add'
296 if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
297 raise GitException, 'Failed git-update-index --rm'
298 if m_files and __run('git-update-index --', m_files) != 0:
299 raise GitException, 'Failed git-update-index'
402ad990
CM
300
301 return True
cfafb945 302
41a6d859 303def commit(message, files = [], parents = [], allowempty = False,
d3cf7d86 304 cache_update = True, tree_id = None,
41a6d859
CM
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 """
41a6d859 309 # Get the tree status
402ad990
CM
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'
41a6d859
CM
314
315 # get the commit message
d3cf7d86
PBG
316 if message[-1:] != '\n':
317 message += '\n'
41a6d859 318
d3cf7d86 319 must_switch = True
41a6d859 320 # write the index to repository
d3cf7d86
PBG
321 if tree_id == None:
322 tree_id = _output_one_line('git-write-tree')
323 else:
324 must_switch = False
41a6d859
CM
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
d3cf7d86
PBG
344 commit_id = _output_one_line(cmd, message)
345 if must_switch:
346 __set_head(commit_id)
41a6d859
CM
347
348 return commit_id
349
350def merge(base, head1, head2):
351 """Perform a 3-way merge between base, head1 and head2 into the
352 local tree
353 """
26dba451 354 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
41a6d859
CM
355 raise GitException, 'git-read-tree failed (local changes maybe?)'
356
357 # this can fail if there are conflicts
7c09df84 358 if os.system('git-merge-index -o -q gitmergeonefile.py -a') != 0:
41a6d859
CM
359 raise GitException, 'git-merge-cache failed (possible conflicts)'
360
41a6d859 361def status(files = [], modified = False, new = False, deleted = False,
4d4c0e3a 362 conflict = False, unknown = False, noexclude = False):
41a6d859
CM
363 """Show the tree status
364 """
4d4c0e3a 365 cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
41a6d859
CM
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:
7371951a 373 filestat.append('A')
41a6d859
CM
374 filestat.append('N')
375 if deleted:
376 filestat.append('D')
377 if conflict:
378 filestat.append('C')
379 if unknown:
380 filestat.append('?')
402ad990 381 cache_files = [x for x in cache_files if x[0] in filestat]
41a6d859
CM
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
b4bddc06 389def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
41a6d859
CM
390 """Show the diff between rev1 and rev2
391 """
41a6d859
CM
392
393 if rev2:
b4bddc06 394 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
41a6d859 395 else:
7c09df84
JH
396 os.system('git-update-index --refresh > /dev/null')
397 diff_str = _output(['git-diff-index', '-p', rev1] + files)
b4bddc06
CM
398
399 if out_fd:
400 out_fd.write(diff_str)
401 else:
402 return diff_str
41a6d859
CM
403
404def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
405 """Return the diffstat between rev1 and rev2
406 """
41a6d859 407
26dba451
BL
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'
41a6d859
CM
414 return str
415
416def files(rev1, rev2):
417 """Return the files modified between rev1 and rev2
418 """
41a6d859
CM
419
420 str = ''
26dba451
BL
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))
41a6d859
CM
423
424 return str.rstrip()
425
faed6770
PBG
426def 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
1008fbce 436def checkout(files = [], tree_id = None, force = False):
41a6d859
CM
437 """Check out the given or all files
438 """
1008fbce
CM
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
7c09df84 442 checkout_cmd = 'git-checkout-index -q -u'
41a6d859 443 if force:
1008fbce 444 checkout_cmd += ' -f'
41a6d859 445 if len(files) == 0:
1008fbce 446 checkout_cmd += ' -a'
41a6d859 447 else:
1008fbce 448 checkout_cmd += ' --'
41a6d859 449
1008fbce 450 if __run(checkout_cmd, files) != 0:
7c09df84 451 raise GitException, 'Failed git-checkout-index'
41a6d859
CM
452
453def switch(tree_id):
454 """Switch the tree to the given id
455 """
a5b29a1c
CM
456 if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
457 raise GitException, 'git-read-tree failed (local changes maybe?)'
41a6d859 458
41a6d859
CM
459 __set_head(tree_id)
460
05d593c0
CM
461def 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
1f5e9148
CM
477def pull(repository = 'origin', refspec = None):
478 """Pull changes from the remote repository. At the moment, just
479 use the 'git pull' command
f338c3c0 480 """
1f5e9148
CM
481 args = [repository]
482 if refspec:
483 args.append(refspec)
f338c3c0 484
ddbbfd84 485 if __run('git pull', args) != 0:
1f5e9148 486 raise GitException, 'Failed "git pull %s"' % repository
f338c3c0 487
0d2cd1e4
CM
488def 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 """
7c09df84 492 os.system('git-update-index --refresh > /dev/null')
0d2cd1e4
CM
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)
1008fbce
CM
499
500def 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)