chiark / gitweb /
Handle 'A' flag for new files
[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
0d2cd1e4
CM
94def _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
26dba451
BL
103def _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
110def _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)
41a6d859
CM
115 return string
116
26dba451
BL
117def _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
124def __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
41a6d859
CM
143def __check_base_dir():
144 return os.path.isdir(base_dir)
145
146def __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')
26dba451 156 extra_exclude = []
41a6d859 157 if os.path.exists(exclude_file):
26dba451
BL
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]
41a6d859
CM
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
26dba451
BL
172 for line in _output_lines(['git-diff-cache', '-r', tree_id] + files):
173 fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
41a6d859
CM
174 if fs[1] not in conflicts:
175 cache_files.append(fs)
41a6d859
CM
176
177 return cache_files
178
179def local_changes():
180 """Return true if there are local changes in the tree
181 """
182 return len(__tree_status()) != 0
183
184def get_head():
185 """Returns a string representing the HEAD
186 """
187 return read_string(head_link)
188
189def 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
198def __set_head(val):
199 """Sets the HEAD value
200 """
201 write_string(head_link, val)
202
203def 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
26dba451
BL
223 if files:
224 if __run('git-update-cache --add --', files):
225 raise GitException, 'Unable to add file'
41a6d859
CM
226
227def 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
26dba451
BL
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)
41a6d859 244
cfafb945
CM
245def update_cache(files):
246 """Update the cache information for the given files
247 """
26dba451
BL
248 files_here = []
249 files_gone = []
250
cfafb945
CM
251 for f in files:
252 if os.path.exists(f):
26dba451 253 files_here.append(f)
cfafb945 254 else:
26dba451
BL
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)
cfafb945 261
41a6d859
CM
262def 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
c4a1503a 277 if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'A', 'D'],
41a6d859
CM
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:
26dba451
BL
291 add_files=[]
292 rm_files=[]
293 m_files=[]
41a6d859 294 for f in cache_files:
c4a1503a 295 if f[0] in ['N', 'A']:
26dba451 296 add_files.append(f[1])
41a6d859 297 elif f[0] == 'D':
26dba451 298 rm_files.append(f[1])
41a6d859 299 else:
26dba451
BL
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'
41a6d859
CM
311
312 # write the index to repository
26dba451 313 tree_id = _output_one_line('git-write-tree')
41a6d859
CM
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
26dba451 335 commit_id = _output_one_line(cmd)
41a6d859
CM
336 __set_head(commit_id)
337 os.remove('.commitmsg')
338
339 return commit_id
340
341def merge(base, head1, head2):
342 """Perform a 3-way merge between base, head1 and head2 into the
343 local tree
344 """
26dba451 345 if __run('git-read-tree -u -m', [base, head1, head2]) != 0:
41a6d859
CM
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
356def 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:
7371951a 368 filestat.append('A')
41a6d859
CM
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
b4bddc06 384def diff(files = [], rev1 = 'HEAD', rev2 = None, out_fd = None):
41a6d859
CM
385 """Show the diff between rev1 and rev2
386 """
41a6d859
CM
387 os.system('git-update-cache --refresh > /dev/null')
388
389 if rev2:
b4bddc06 390 diff_str = _output(['git-diff-tree', '-p', rev1, rev2] + files)
41a6d859 391 else:
b4bddc06
CM
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
41a6d859
CM
398
399def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
400 """Return the diffstat between rev1 and rev2
401 """
41a6d859
CM
402
403 os.system('git-update-cache --refresh > /dev/null')
26dba451
BL
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'
41a6d859
CM
410 return str
411
412def 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 = ''
26dba451
BL
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))
41a6d859
CM
420
421 return str.rstrip()
422
423def checkout(files = [], force = False):
424 """Check out the given or all files
425 """
26dba451 426 git_flags = 'git-checkout-cache -q -u'
41a6d859
CM
427 if force:
428 git_flags += ' -f'
429 if len(files) == 0:
430 git_flags += ' -a'
431 else:
26dba451 432 git_flags += ' --'
41a6d859 433
26dba451
BL
434 if __run(git_flags, files) != 0:
435 raise GitException, 'Failed git-checkout-cache'
41a6d859
CM
436
437def switch(tree_id):
438 """Switch the tree to the given id
439 """
c4a1503a
PO
440 to_delete = filter(lambda x: x[0] in ['N', 'A'],
441 __tree_status(tree_id = tree_id))
41a6d859 442
26dba451 443 if __run('git-read-tree -m', [tree_id]) != 0:
41a6d859
CM
444 raise GitException, 'Failed git-read-tree -m %s' % tree_id
445
446 checkout(force = True)
447 __set_head(tree_id)
448
449 # checkout doesn't remove files
450 for fs in to_delete:
451 os.remove(fs[1])
f338c3c0
CM
452
453def fetch(location, head = None, tag = None):
454 """Fetch changes from the remote repository. At the moment, just
455 use the 'git fetch' scripts
456 """
457 args = [location]
458 if head:
459 args += [head]
460 elif tag:
461 args += ['tag', tag]
462
463 if __run('git fetch', args) != 0:
464 raise GitException, 'Failed "git fetch %s"' % location
465
466 return read_string(os.path.join(base_dir, 'FETCH_HEAD'))
0d2cd1e4
CM
467
468def apply_patch(filename = None):
469 """Apply a patch onto the current index. There must not be any
470 local changes in the tree, otherwise the command fails
471 """
472 os.system('git-update-cache --refresh > /dev/null')
473
474 if filename:
475 if __run('git-apply --index', [filename]) != 0:
476 raise GitException, 'Patch does not apply cleanly'
477 else:
478 _input('git-apply --index', sys.stdin)