Allow the GIT ids to be more flexible
[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
78
79#
80# Functions
81#
82def get_conflicts():
83 """Return the list of file conflicts
84 """
85 conflicts_file = os.path.join(base_dir, 'conflicts')
86 if os.path.isfile(conflicts_file):
87 f = file(conflicts_file)
88 names = [line.strip() for line in f.readlines()]
89 f.close()
90 return names
91 else:
92 return None
93
26dba451
BL
94def _output(cmd):
95 p=popen2.Popen3(cmd)
96 string = p.fromchild.read()
97 if p.wait():
98 raise GitException, '%s failed' % str(cmd)
99 return string
100
101def _output_one_line(cmd):
102 p=popen2.Popen3(cmd)
103 string = p.fromchild.readline().strip()
104 if p.wait():
105 raise GitException, '%s failed' % str(cmd)
41a6d859
CM
106 return string
107
26dba451
BL
108def _output_lines(cmd):
109 p=popen2.Popen3(cmd)
110 lines = p.fromchild.readlines()
111 if p.wait():
112 raise GitException, '%s failed' % str(cmd)
113 return lines
114
115def __run(cmd, args=None):
116 """__run: runs cmd using spawnvp.
117
118 Runs cmd using spawnvp. The shell is avoided so it won't mess up
119 our arguments. If args is very large, the command is run multiple
120 times; args is split xargs style: cmd is passed on each
121 invocation. Unlike xargs, returns immediately if any non-zero
122 return code is received.
123 """
124
125 args_l=cmd.split()
126 if args is None:
127 args = []
128 for i in range(0, len(args)+1, 100):
129 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
130 if r:
131 return r
132 return 0
133
41a6d859
CM
134def __check_base_dir():
135 return os.path.isdir(base_dir)
136
137def __tree_status(files = [], tree_id = 'HEAD', unknown = False):
138 """Returns a list of pairs - [status, filename]
139 """
140 os.system('git-update-cache --refresh > /dev/null')
141
142 cache_files = []
143
144 # unknown files
145 if unknown:
146 exclude_file = os.path.join(base_dir, 'exclude')
26dba451 147 extra_exclude = []
41a6d859 148 if os.path.exists(exclude_file):
26dba451
BL
149 extra_exclude.append('--exclude-from=%s' % exclude_file)
150 lines = _output_lines(['git-ls-files', '--others',
151 '--exclude=*.[ao]', '--exclude=.*'
152 '--exclude=TAGS', '--exclude=tags', '--exclude=*~',
153 '--exclude=#*'] + extra_exclude)
154 cache_files += [('?', line.strip()) for line in lines]
41a6d859
CM
155
156 # conflicted files
157 conflicts = get_conflicts()
158 if not conflicts:
159 conflicts = []
160 cache_files += [('C', filename) for filename in conflicts]
161
162 # the rest
26dba451
BL
163 for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
164 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
41a6d859
CM
165 if fs[1] not in conflicts:
166 cache_files.append(fs)
41a6d859
CM
167
168 return cache_files
169
170def local_changes():
171 """Return true if there are local changes in the tree
172 """
173 return len(__tree_status()) != 0
174
175def get_head():
176 """Returns a string representing the HEAD
177 """
178 return read_string(head_link)
179
180def get_head_file():
181 """Returns the name of the file pointed to by the HEAD link
182 """
183 # valid link
184 if os.path.islink(head_link) and os.path.isfile(head_link):
185 return os.path.basename(os.readlink(head_link))
186 else:
187 raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
188
189def __set_head(val):
190 """Sets the HEAD value
191 """
192 write_string(head_link, val)
193
194def add(names):
195 """Add the files or recursively add the directory contents
196 """
197 # generate the file list
198 files = []
199 for i in names:
200 if not os.path.exists(i):
201 raise GitException, 'Unknown file or directory: %s' % i
202
203 if os.path.isdir(i):
204 # recursive search. We only add files
205 for root, dirs, local_files in os.walk(i):
206 for name in [os.path.join(root, f) for f in local_files]:
207 if os.path.isfile(name):
208 files.append(os.path.normpath(name))
209 elif os.path.isfile(i):
210 files.append(os.path.normpath(i))
211 else:
212 raise GitException, '%s is not a file or directory' % i
213
26dba451
BL
214 if files:
215 if __run('git-update-cache --add --', files):
216 raise GitException, 'Unable to add file'
41a6d859
CM
217
218def rm(files, force = False):
219 """Remove a file from the repository
220 """
221 if force:
222 git_opt = '--force-remove'
223 else:
224 git_opt = '--remove'
225
26dba451
BL
226 if not force:
227 for f in files:
228 if os.path.exists(f):
229 raise GitException, '%s exists. Remove it first' %f
230 if files:
231 __run('git-update-cache --remove --', files)
232 else:
233 if files:
234 __run('git-update-cache --force-remove --', files)
41a6d859 235
cfafb945
CM
236def update_cache(files):
237 """Update the cache information for the given files
238 """
26dba451
BL
239 files_here = []
240 files_gone = []
241
cfafb945
CM
242 for f in files:
243 if os.path.exists(f):
26dba451 244 files_here.append(f)
cfafb945 245 else:
26dba451
BL
246 files_gone.append(f)
247
248 if files_here:
249 __run('git-update-cache --', files_here)
250 if files_gone:
251 __run('git-update-cache --remove --', files_gone)
cfafb945 252
41a6d859
CM
253def commit(message, files = [], parents = [], allowempty = False,
254 author_name = None, author_email = None, author_date = None,
255 committer_name = None, committer_email = None):
256 """Commit the current tree to repository
257 """
258 first = (parents == [])
259
260 # Get the tree status
261 if not first:
262 cache_files = __tree_status(files)
263
264 if not first and len(cache_files) == 0 and not allowempty:
265 raise GitException, 'No changes to commit'
266
267 # check for unresolved conflicts
268 if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'D'],
269 cache_files)) != 0:
270 raise GitException, 'Commit failed: unresolved conflicts'
271
272 # get the commit message
273 f = file('.commitmsg', 'w+')
274 if message[-1] == '\n':
275 f.write(message)
276 else:
277 print >> f, message
278 f.close()
279
280 # update the cache
281 if not first:
26dba451
BL
282 add_files=[]
283 rm_files=[]
284 m_files=[]
41a6d859
CM
285 for f in cache_files:
286 if f[0] == 'N':
26dba451 287 add_files.append(f[1])
41a6d859 288 elif f[0] == 'D':
26dba451 289 rm_files.append(f[1])
41a6d859 290 else:
26dba451
BL
291 m_files.append(f[1])
292
293 if add_files:
294 if __run('git-update-cache --add --', add_files):
295 raise GitException, 'Failed git-update-cache --add'
296 if rm_files:
297 if __run('git-update-cache --force-remove --', rm_files):
298 raise GitException, 'Failed git-update-cache --rm'
299 if m_files:
300 if __run('git-update-cache --', m_files):
301 raise GitException, 'Failed git-update-cache'
41a6d859
CM
302
303 # write the index to repository
26dba451 304 tree_id = _output_one_line('git-write-tree')
41a6d859
CM
305
306 # the commit
307 cmd = ''
308 if author_name:
309 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
310 if author_email:
311 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
312 if author_date:
313 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
314 if committer_name:
315 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
316 if committer_email:
317 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
318 cmd += 'git-commit-tree %s' % tree_id
319
320 # get the parents
321 for p in parents:
322 cmd += ' -p %s' % p
323
324 cmd += ' < .commitmsg'
325
26dba451 326 commit_id = _output_one_line(cmd)
41a6d859
CM
327 __set_head(commit_id)
328 os.remove('.commitmsg')
329
330 return commit_id
331
332def merge(base, head1, head2):
333 """Perform a 3-way merge between base, head1 and head2 into the
334 local tree
335 """
26dba451 336 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
41a6d859
CM
337 raise GitException, 'git-read-tree failed (local changes maybe?)'
338
339 # this can fail if there are conflicts
340 if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
341 raise GitException, 'git-merge-cache failed (possible conflicts)'
342
343 # this should not fail
344 if os.system('git-checkout-cache -f -a') != 0:
345 raise GitException, 'Failed git-checkout-cache'
346
347def status(files = [], modified = False, new = False, deleted = False,
348 conflict = False, unknown = False):
349 """Show the tree status
350 """
351 cache_files = __tree_status(files, unknown = True)
352 all = not (modified or new or deleted or conflict or unknown)
353
354 if not all:
355 filestat = []
356 if modified:
357 filestat.append('M')
358 if new:
7371951a 359 filestat.append('A')
41a6d859
CM
360 filestat.append('N')
361 if deleted:
362 filestat.append('D')
363 if conflict:
364 filestat.append('C')
365 if unknown:
366 filestat.append('?')
367 cache_files = filter(lambda x: x[0] in filestat, cache_files)
368
369 for fs in cache_files:
370 if all:
371 print '%s %s' % (fs[0], fs[1])
372 else:
373 print '%s' % fs[1]
374
b4bddc06 375def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
41a6d859
CM
376 """Show the diff between rev1 and rev2
377 """
41a6d859
CM
378 os.system('git-update-cache --refresh > /dev/null')
379
380 if rev2:
b4bddc06 381 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
41a6d859 382 else:
b4bddc06
CM
383 diff_str = _output(['git-diff-cache', '-p', rev1] + files)
384
385 if out_fd:
386 out_fd.write(diff_str)
387 else:
388 return diff_str
41a6d859
CM
389
390def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
391 """Return the diffstat between rev1 and rev2
392 """
41a6d859
CM
393
394 os.system('git-update-cache --refresh > /dev/null')
26dba451
BL
395 p=popen2.Popen3('git-apply --stat')
396 diff(files, rev1, rev2, p.tochild)
397 p.tochild.close()
398 str = p.fromchild.read().rstrip()
399 if p.wait():
400 raise GitException, 'git.diffstat failed'
41a6d859
CM
401 return str
402
403def files(rev1, rev2):
404 """Return the files modified between rev1 and rev2
405 """
406 os.system('git-update-cache --refresh > /dev/null')
407
408 str = ''
26dba451
BL
409 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
410 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
41a6d859
CM
411
412 return str.rstrip()
413
414def checkout(files = [], force = False):
415 """Check out the given or all files
416 """
26dba451 417 git_flags = 'git-checkout-cache -q -u'
41a6d859
CM
418 if force:
419 git_flags += ' -f'
420 if len(files) == 0:
421 git_flags += ' -a'
422 else:
26dba451 423 git_flags += ' --'
41a6d859 424
26dba451
BL
425 if __run(git_flags, files) != 0:
426 raise GitException, 'Failed git-checkout-cache'
41a6d859
CM
427
428def switch(tree_id):
429 """Switch the tree to the given id
430 """
431 to_delete = filter(lambda x: x[0] == 'N', __tree_status(tree_id = tree_id))
432
26dba451 433 if __run('git-read-tree -m', [tree_id]) != 0:
41a6d859
CM
434 raise GitException, 'Failed git-read-tree -m %s' % tree_id
435
436 checkout(force = True)
437 __set_head(tree_id)
438
439 # checkout doesn't remove files
440 for fs in to_delete:
441 os.remove(fs[1])
f338c3c0
CM
442
443def fetch(location, head = None, tag = None):
444 """Fetch changes from the remote repository. At the moment, just
445 use the 'git fetch' scripts
446 """
447 args = [location]
448 if head:
449 args += [head]
450 elif tag:
451 args += ['tag', tag]
452
453 if __run('git fetch', args) != 0:
454 raise GitException, 'Failed "git fetch %s"' % location
455
456 return read_string(os.path.join(base_dir, 'FETCH_HEAD'))