allow spaces in filenames (second try)
[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 _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
101 def _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
108 def _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
115 def __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
134 def __check_base_dir():
135 return os.path.isdir(base_dir)
136
137 def __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
170 def local_changes():
171 """Return true if there are local changes in the tree
172 """
173 return len(__tree_status()) != 0
174
175 def get_head():
176 """Returns a string representing the HEAD
177 """
178 return read_string(head_link)
179
180 def 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
189 def __set_head(val):
190 """Sets the HEAD value
191 """
192 write_string(head_link, val)
193
194 def 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
218 def 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
236 def 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
253 def 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
332 def 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
347 def 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('N')
360 if deleted:
361 filestat.append('D')
362 if conflict:
363 filestat.append('C')
364 if unknown:
365 filestat.append('?')
366 cache_files = filter(lambda x: x[0] in filestat, cache_files)
367
368 for fs in cache_files:
369 if all:
370 print '%s %s' % (fs[0], fs[1])
371 else:
372 print '%s' % fs[1]
373
374 def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = sys.stdout):
375 """Show the diff between rev1 and rev2
376 """
377 os.system('git-update-cache --refresh > /dev/null')
378
379 if rev2:
380 out_fd.write(_output(['git-diff-tree', '-p', rev1, rev2]+files))
381 else:
382 out_fd.write(_output(['git-diff-cache', '-p', rev1]+files))
383
384 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
385 """Return the diffstat between rev1 and rev2
386 """
387
388 os.system('git-update-cache --refresh > /dev/null')
389 p=popen2.Popen3('git-apply --stat')
390 diff(files, rev1, rev2, p.tochild)
391 p.tochild.close()
392 str = p.fromchild.read().rstrip()
393 if p.wait():
394 raise GitException, 'git.diffstat failed'
395 return str
396
397 def files(rev1, rev2):
398 """Return the files modified between rev1 and rev2
399 """
400 os.system('git-update-cache --refresh > /dev/null')
401
402 str = ''
403 for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
404 str += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
405
406 return str.rstrip()
407
408 def checkout(files = [], force = False):
409 """Check out the given or all files
410 """
411 git_flags = 'git-checkout-cache -q -u'
412 if force:
413 git_flags += ' -f'
414 if len(files) == 0:
415 git_flags += ' -a'
416 else:
417 git_flags += ' --'
418
419 if __run(git_flags, files) != 0:
420 raise GitException, 'Failed git-checkout-cache'
421
422 def switch(tree_id):
423 """Switch the tree to the given id
424 """
425 to_delete = filter(lambda x: x[0] == 'N', __tree_status(tree_id = tree_id))
426
427 if __run('git-read-tree -m', [tree_id]) != 0:
428 raise GitException, 'Failed git-read-tree -m %s' % tree_id
429
430 checkout(force = True)
431 __set_head(tree_id)
432
433 # checkout doesn't remove files
434 for fs in to_delete:
435 os.remove(fs[1])