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