Allow the GIT ids to be more flexible
[stgit] / stgit / git.py
... / ...
CommitLineData
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
21import sys, os, glob, popen2
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
38#
39# Classes
40#
41class 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
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
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)
106 return string
107
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
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')
147 extra_exclude = []
148 if os.path.exists(exclude_file):
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]
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
163 for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
164 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
165 if fs[1] not in conflicts:
166 cache_files.append(fs)
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
214 if files:
215 if __run('git-update-cache --add --', files):
216 raise GitException, 'Unable to add file'
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
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)
235
236def update_cache(files):
237 """Update the cache information for the given files
238 """
239 files_here = []
240 files_gone = []
241
242 for f in files:
243 if os.path.exists(f):
244 files_here.append(f)
245 else:
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)
252
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:
282 add_files=[]
283 rm_files=[]
284 m_files=[]
285 for f in cache_files:
286 if f[0] == 'N':
287 add_files.append(f[1])
288 elif f[0] == 'D':
289 rm_files.append(f[1])
290 else:
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'
302
303 # write the index to repository
304 tree_id = _output_one_line('git-write-tree')
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
326 commit_id = _output_one_line(cmd)
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 """
336 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
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:
359 filestat.append('A')
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
375def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
376 """Show the diff between rev1 and rev2
377 """
378 os.system('git-update-cache --refresh > /dev/null')
379
380 if rev2:
381 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
382 else:
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
389
390def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
391 """Return the diffstat between rev1 and rev2
392 """
393
394 os.system('git-update-cache --refresh > /dev/null')
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'
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 = ''
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))
411
412 return str.rstrip()
413
414def checkout(files = [], force = False):
415 """Check out the given or all files
416 """
417 git_flags = 'git-checkout-cache -q -u'
418 if force:
419 git_flags += ' -f'
420 if len(files) == 0:
421 git_flags += ' -a'
422 else:
423 git_flags += ' --'
424
425 if __run(git_flags, files) != 0:
426 raise GitException, 'Failed git-checkout-cache'
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
433 if __run('git-read-tree -m', [tree_id]) != 0:
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])
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'))