Implement an 'import' command
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, glob, popen2
22
23 from stgit.utils import *
24
25 # git exception class
26 class GitException(Exception):
27 pass
28
29
30 # Different start-up variables read from the environment
31 if 'GIT_DIR' in os.environ:
32 base_dir = os.environ['GIT_DIR']
33 else:
34 base_dir = '.git'
35
36 head_link = os.path.join(base_dir, 'HEAD')
37
38 #
39 # Classes
40 #
41 class 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 #
82 def 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
94 def _input(cmd, file_desc):
95 p = popen2.Popen3(cmd)
96 for line in file_desc:
97 print line
98 p.tochild.write(line)
99 p.tochild.close()
100 if p.wait():
101 raise GitException, '%s failed' % str(cmd)
102
103 def _output(cmd):
104 p=popen2.Popen3(cmd)
105 string = p.fromchild.read()
106 if p.wait():
107 raise GitException, '%s failed' % str(cmd)
108 return string
109
110 def _output_one_line(cmd):
111 p=popen2.Popen3(cmd)
112 string = p.fromchild.readline().strip()
113 if p.wait():
114 raise GitException, '%s failed' % str(cmd)
115 return string
116
117 def _output_lines(cmd):
118 p=popen2.Popen3(cmd)
119 lines = p.fromchild.readlines()
120 if p.wait():
121 raise GitException, '%s failed' % str(cmd)
122 return lines
123
124 def __run(cmd, args=None):
125 """__run: runs cmd using spawnvp.
126
127 Runs cmd using spawnvp. The shell is avoided so it won't mess up
128 our arguments. If args is very large, the command is run multiple
129 times; args is split xargs style: cmd is passed on each
130 invocation. Unlike xargs, returns immediately if any non-zero
131 return code is received.
132 """
133
134 args_l=cmd.split()
135 if args is None:
136 args = []
137 for i in range(0, len(args)+1, 100):
138 r=os.spawnvp(os.P_WAIT, args_l[0], args_l + args[i:min(i+100, len(args))])
139 if r:
140 return r
141 return 0
142
143 def __check_base_dir():
144 return os.path.isdir(base_dir)
145
146 def __tree_status(files = [], tree_id = 'HEAD', unknown = False):
147 """Returns a list of pairs - [status, filename]
148 """
149 os.system('git-update-cache --refresh > /dev/null')
150
151 cache_files = []
152
153 # unknown files
154 if unknown:
155 exclude_file = os.path.join(base_dir, 'exclude')
156 extra_exclude = []
157 if os.path.exists(exclude_file):
158 extra_exclude.append('--exclude-from=%s' % exclude_file)
159 lines = _output_lines(['git-ls-files', '--others',
160 '--exclude=*.[ao]', '--exclude=.*'
161 '--exclude=TAGS', '--exclude=tags', '--exclude=*~',
162 '--exclude=#*'] + extra_exclude)
163 cache_files += [('?', line.strip()) for line in lines]
164
165 # conflicted files
166 conflicts = get_conflicts()
167 if not conflicts:
168 conflicts = []
169 cache_files += [('C', filename) for filename in conflicts]
170
171 # the rest
172 for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
173 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
174 if fs[1] not in conflicts:
175 cache_files.append(fs)
176
177 return cache_files
178
179 def local_changes():
180 """Return true if there are local changes in the tree
181 """
182 return len(__tree_status()) != 0
183
184 def get_head():
185 """Returns a string representing the HEAD
186 """
187 return read_string(head_link)
188
189 def get_head_file():
190 """Returns the name of the file pointed to by the HEAD link
191 """
192 # valid link
193 if os.path.islink(head_link) and os.path.isfile(head_link):
194 return os.path.basename(os.readlink(head_link))
195 else:
196 raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
197
198 def __set_head(val):
199 """Sets the HEAD value
200 """
201 write_string(head_link, val)
202
203 def add(names):
204 """Add the files or recursively add the directory contents
205 """
206 # generate the file list
207 files = []
208 for i in names:
209 if not os.path.exists(i):
210 raise GitException, 'Unknown file or directory: %s' % i
211
212 if os.path.isdir(i):
213 # recursive search. We only add files
214 for root, dirs, local_files in os.walk(i):
215 for name in [os.path.join(root, f) for f in local_files]:
216 if os.path.isfile(name):
217 files.append(os.path.normpath(name))
218 elif os.path.isfile(i):
219 files.append(os.path.normpath(i))
220 else:
221 raise GitException, '%s is not a file or directory' % i
222
223 if files:
224 if __run('git-update-cache --add --', files):
225 raise GitException, 'Unable to add file'
226
227 def rm(files, force = False):
228 """Remove a file from the repository
229 """
230 if force:
231 git_opt = '--force-remove'
232 else:
233 git_opt = '--remove'
234
235 if not force:
236 for f in files:
237 if os.path.exists(f):
238 raise GitException, '%s exists. Remove it first' %f
239 if files:
240 __run('git-update-cache --remove --', files)
241 else:
242 if files:
243 __run('git-update-cache --force-remove --', files)
244
245 def update_cache(files):
246 """Update the cache information for the given files
247 """
248 files_here = []
249 files_gone = []
250
251 for f in files:
252 if os.path.exists(f):
253 files_here.append(f)
254 else:
255 files_gone.append(f)
256
257 if files_here:
258 __run('git-update-cache --', files_here)
259 if files_gone:
260 __run('git-update-cache --remove --', files_gone)
261
262 def commit(message, files = [], parents = [], allowempty = False,
263 author_name = None, author_email = None, author_date = None,
264 committer_name = None, committer_email = None):
265 """Commit the current tree to repository
266 """
267 first = (parents == [])
268
269 # Get the tree status
270 if not first:
271 cache_files = __tree_status(files)
272
273 if not first and len(cache_files) == 0 and not allowempty:
274 raise GitException, 'No changes to commit'
275
276 # check for unresolved conflicts
277 if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'D'],
278 cache_files)) != 0:
279 raise GitException, 'Commit failed: unresolved conflicts'
280
281 # get the commit message
282 f = file('.commitmsg', 'w+')
283 if message[-1] == '\n':
284 f.write(message)
285 else:
286 print >> f, message
287 f.close()
288
289 # update the cache
290 if not first:
291 add_files=[]
292 rm_files=[]
293 m_files=[]
294 for f in cache_files:
295 if f[0] == 'N':
296 add_files.append(f[1])
297 elif f[0] == 'D':
298 rm_files.append(f[1])
299 else:
300 m_files.append(f[1])
301
302 if add_files:
303 if __run('git-update-cache --add --', add_files):
304 raise GitException, 'Failed git-update-cache --add'
305 if rm_files:
306 if __run('git-update-cache --force-remove --', rm_files):
307 raise GitException, 'Failed git-update-cache --rm'
308 if m_files:
309 if __run('git-update-cache --', m_files):
310 raise GitException, 'Failed git-update-cache'
311
312 # write the index to repository
313 tree_id = _output_one_line('git-write-tree')
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
335 commit_id = _output_one_line(cmd)
336 __set_head(commit_id)
337 os.remove('.commitmsg')
338
339 return commit_id
340
341 def merge(base, head1, head2):
342 """Perform a 3-way merge between base, head1 and head2 into the
343 local tree
344 """
345 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
346 raise GitException, 'git-read-tree failed (local changes maybe?)'
347
348 # this can fail if there are conflicts
349 if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
350 raise GitException, 'git-merge-cache failed (possible conflicts)'
351
352 # this should not fail
353 if os.system('git-checkout-cache -f -a') != 0:
354 raise GitException, 'Failed git-checkout-cache'
355
356 def status(files = [], modified = False, new = False, deleted = False,
357 conflict = False, unknown = False):
358 """Show the tree status
359 """
360 cache_files = __tree_status(files, unknown = True)
361 all = not (modified or new or deleted or conflict or unknown)
362
363 if not all:
364 filestat = []
365 if modified:
366 filestat.append('M')
367 if new:
368 filestat.append('A')
369 filestat.append('N')
370 if deleted:
371 filestat.append('D')
372 if conflict:
373 filestat.append('C')
374 if unknown:
375 filestat.append('?')
376 cache_files = filter(lambda x: x[0] in filestat, cache_files)
377
378 for fs in cache_files:
379 if all:
380 print '%s %s' % (fs[0], fs[1])
381 else:
382 print '%s' % fs[1]
383
384 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
385 """Show the diff between rev1 and rev2
386 """
387 os.system('git-update-cache --refresh > /dev/null')
388
389 if rev2:
390 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
391 else:
392 diff_str = _output(['git-diff-cache', '-p', rev1] + files)
393
394 if out_fd:
395 out_fd.write(diff_str)
396 else:
397 return diff_str
398
399 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
400 """Return the diffstat between rev1 and rev2
401 """
402
403 os.system('git-update-cache --refresh > /dev/null')
404 p=popen2.Popen3('git-apply --stat')
405 diff(files, rev1, rev2, p.tochild)
406 p.tochild.close()
407 str = p.fromchild.read().rstrip()
408 if p.wait():
409 raise GitException, 'git.diffstat failed'
410 return str
411
412 def files(rev1, rev2):
413 """Return the files modified between rev1 and rev2
414 """
415 os.system('git-update-cache --refresh > /dev/null')
416
417 str = ''
418 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
419 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
420
421 return str.rstrip()
422
423 def checkout(files = [], force = False):
424 """Check out the given or all files
425 """
426 git_flags = 'git-checkout-cache -q -u'
427 if force:
428 git_flags += ' -f'
429 if len(files) == 0:
430 git_flags += ' -a'
431 else:
432 git_flags += ' --'
433
434 if __run(git_flags, files) != 0:
435 raise GitException, 'Failed git-checkout-cache'
436
437 def switch(tree_id):
438 """Switch the tree to the given id
439 """
440 to_delete = filter(lambda x: x[0] == 'N', __tree_status(tree_id = tree_id))
441
442 if __run('git-read-tree -m', [tree_id]) != 0:
443 raise GitException, 'Failed git-read-tree -m %s' % tree_id
444
445 checkout(force = True)
446 __set_head(tree_id)
447
448 # checkout doesn't remove files
449 for fs in to_delete:
450 os.remove(fs[1])
451
452 def fetch(location, head = None, tag = None):
453 """Fetch changes from the remote repository. At the moment, just
454 use the 'git fetch' scripts
455 """
456 args = [location]
457 if head:
458 args += [head]
459 elif tag:
460 args += ['tag', tag]
461
462 if __run('git fetch', args) != 0:
463 raise GitException, 'Failed "git fetch %s"' % location
464
465 return read_string(os.path.join(base_dir, 'FETCH_HEAD'))
466
467 def apply_patch(filename = None):
468 """Apply a patch onto the current index. There must not be any
469 local changes in the tree, otherwise the command fails
470 """
471 os.system('git-update-cache --refresh > /dev/null')
472
473 if filename:
474 if __run('git-apply --index', [filename]) != 0:
475 raise GitException, 'Patch does not apply cleanly'
476 else:
477 _input('git-apply --index', sys.stdin)