Initial commit (Release 0.4)
[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
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 #
40 # Classes
41 #
42 class Commit:
43 """Handle the commit objects
44 """
45 def __init__(self, id_hash):
46 self.__id_hash = id_hash
47 f = os.popen('git-cat-file commit %s' % id_hash, 'r')
48
49 for line in f:
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 = f.read()
62
63 if f.close():
64 raise GitException, 'Unknown commit id'
65
66 def get_id_hash(self):
67 return self.__id_hash
68
69 def get_tree(self):
70 return self.__tree
71
72 def get_parent(self):
73 return self.__parent
74
75 def get_author(self):
76 return self.__author
77
78 def get_committer(self):
79 return self.__committer
80
81
82 #
83 # Functions
84 #
85 def get_conflicts():
86 """Return the list of file conflicts
87 """
88 conflicts_file = os.path.join(base_dir, 'conflicts')
89 if os.path.isfile(conflicts_file):
90 f = file(conflicts_file)
91 names = [line.strip() for line in f.readlines()]
92 f.close()
93 return names
94 else:
95 return None
96
97 def __output(cmd):
98 f = os.popen(cmd, 'r')
99 string = f.readline().strip()
100 if f.close():
101 raise GitException, '%s failed' % cmd
102 return string
103
104 def __check_base_dir():
105 return os.path.isdir(base_dir)
106
107 def __tree_status(files = [], tree_id = 'HEAD', unknown = False):
108 """Returns a list of pairs - [status, filename]
109 """
110 os.system('git-update-cache --refresh > /dev/null')
111
112 cache_files = []
113
114 # unknown files
115 if unknown:
116 exclude_file = os.path.join(base_dir, 'exclude')
117 extra_exclude = ''
118 if os.path.exists(exclude_file):
119 extra_exclude += ' --exclude-from=%s' % exclude_file
120 fout = os.popen('git-ls-files --others'
121 ' --exclude="*.[ao]" --exclude=".*"'
122 ' --exclude=TAGS --exclude=tags --exclude="*~"'
123 ' --exclude="#*"' + extra_exclude, 'r')
124 cache_files += [('?', line.strip()) for line in fout]
125
126 # conflicted files
127 conflicts = get_conflicts()
128 if not conflicts:
129 conflicts = []
130 cache_files += [('C', filename) for filename in conflicts]
131
132 # the rest
133 files_str = reduce(lambda x, y: x + ' ' + y, files, '')
134 fout = os.popen('git-diff-cache -r %s %s' % (tree_id, files_str), 'r')
135 for line in fout:
136 fs = tuple(line.split()[4:])
137 if fs[1] not in conflicts:
138 cache_files.append(fs)
139 if fout.close():
140 raise GitException, 'git-diff-cache failed'
141
142 return cache_files
143
144 def local_changes():
145 """Return true if there are local changes in the tree
146 """
147 return len(__tree_status()) != 0
148
149 def get_head():
150 """Returns a string representing the HEAD
151 """
152 return read_string(head_link)
153
154 def get_head_file():
155 """Returns the name of the file pointed to by the HEAD link
156 """
157 # valid link
158 if os.path.islink(head_link) and os.path.isfile(head_link):
159 return os.path.basename(os.readlink(head_link))
160 else:
161 raise GitException, 'Invalid .git/HEAD link. Git tree not initialised?'
162
163 def __set_head(val):
164 """Sets the HEAD value
165 """
166 write_string(head_link, val)
167
168 def add(names):
169 """Add the files or recursively add the directory contents
170 """
171 # generate the file list
172 files = []
173 for i in names:
174 if not os.path.exists(i):
175 raise GitException, 'Unknown file or directory: %s' % i
176
177 if os.path.isdir(i):
178 # recursive search. We only add files
179 for root, dirs, local_files in os.walk(i):
180 for name in [os.path.join(root, f) for f in local_files]:
181 if os.path.isfile(name):
182 files.append(os.path.normpath(name))
183 elif os.path.isfile(i):
184 files.append(os.path.normpath(i))
185 else:
186 raise GitException, '%s is not a file or directory' % i
187
188 for f in files:
189 print 'Adding file %s' % f
190 if os.system('git-update-cache --add -- %s' % f) != 0:
191 raise GitException, 'Unable to add %s' % f
192
193 def rm(files, force = False):
194 """Remove a file from the repository
195 """
196 if force:
197 git_opt = '--force-remove'
198 else:
199 git_opt = '--remove'
200
201 for f in files:
202 if force:
203 print 'Removing file %s' % f
204 if os.system('git-update-cache --force-remove -- %s' % f) != 0:
205 raise GitException, 'Unable to remove %s' % f
206 elif os.path.exists(f):
207 raise GitException, '%s exists. Remove it first' %f
208 else:
209 print 'Removing file %s' % f
210 if os.system('git-update-cache --remove -- %s' % f) != 0:
211 raise GitException, 'Unable to remove %s' % f
212
213 def commit(message, files = [], parents = [], allowempty = False,
214 author_name = None, author_email = None, author_date = None,
215 committer_name = None, committer_email = None):
216 """Commit the current tree to repository
217 """
218 first = (parents == [])
219
220 # Get the tree status
221 if not first:
222 cache_files = __tree_status(files)
223
224 if not first and len(cache_files) == 0 and not allowempty:
225 raise GitException, 'No changes to commit'
226
227 # check for unresolved conflicts
228 if not first and len(filter(lambda x: x[0] not in ['M', 'N', 'D'],
229 cache_files)) != 0:
230 raise GitException, 'Commit failed: unresolved conflicts'
231
232 # get the commit message
233 f = file('.commitmsg', 'w+')
234 if message[-1] == '\n':
235 f.write(message)
236 else:
237 print >> f, message
238 f.close()
239
240 # update the cache
241 if not first:
242 for f in cache_files:
243 if f[0] == 'N':
244 git_flag = '--add'
245 elif f[0] == 'D':
246 git_flag = '--force-remove'
247 else:
248 git_flag = '--'
249
250 if os.system('git-update-cache %s %s' % (git_flag, f[1])) != 0:
251 raise GitException, 'Failed git-update-cache -- %s' % f[1]
252
253 # write the index to repository
254 tree_id = __output('git-write-tree')
255
256 # the commit
257 cmd = ''
258 if author_name:
259 cmd += 'GIT_AUTHOR_NAME="%s" ' % author_name
260 if author_email:
261 cmd += 'GIT_AUTHOR_EMAIL="%s" ' % author_email
262 if author_date:
263 cmd += 'GIT_AUTHOR_DATE="%s" ' % author_date
264 if committer_name:
265 cmd += 'GIT_COMMITTER_NAME="%s" ' % committer_name
266 if committer_email:
267 cmd += 'GIT_COMMITTER_EMAIL="%s" ' % committer_email
268 cmd += 'git-commit-tree %s' % tree_id
269
270 # get the parents
271 for p in parents:
272 cmd += ' -p %s' % p
273
274 cmd += ' < .commitmsg'
275
276 commit_id = __output(cmd)
277 __set_head(commit_id)
278 os.remove('.commitmsg')
279
280 return commit_id
281
282 def merge(base, head1, head2):
283 """Perform a 3-way merge between base, head1 and head2 into the
284 local tree
285 """
286 if os.system('git-read-tree -u -m %s %s %s' % (base, head1, head2)) != 0:
287 raise GitException, 'git-read-tree failed (local changes maybe?)'
288
289 # this can fail if there are conflicts
290 if os.system('git-merge-cache -o gitmergeonefile.py -a') != 0:
291 raise GitException, 'git-merge-cache failed (possible conflicts)'
292
293 # this should not fail
294 if os.system('git-checkout-cache -f -a') != 0:
295 raise GitException, 'Failed git-checkout-cache'
296
297 def status(files = [], modified = False, new = False, deleted = False,
298 conflict = False, unknown = False):
299 """Show the tree status
300 """
301 cache_files = __tree_status(files, unknown = True)
302 all = not (modified or new or deleted or conflict or unknown)
303
304 if not all:
305 filestat = []
306 if modified:
307 filestat.append('M')
308 if new:
309 filestat.append('N')
310 if deleted:
311 filestat.append('D')
312 if conflict:
313 filestat.append('C')
314 if unknown:
315 filestat.append('?')
316 cache_files = filter(lambda x: x[0] in filestat, cache_files)
317
318 for fs in cache_files:
319 if all:
320 print '%s %s' % (fs[0], fs[1])
321 else:
322 print '%s' % fs[1]
323
324 def diff(files = [], rev1 = 'HEAD', rev2 = None, output = None,
325 append = False):
326 """Show the diff between rev1 and rev2
327 """
328 files_str = reduce(lambda x, y: x + ' ' + y, files, '')
329
330 extra_args = ''
331 if output:
332 if append:
333 extra_args += ' >> %s' % output
334 else:
335 extra_args += ' > %s' % output
336
337 os.system('git-update-cache --refresh > /dev/null')
338
339 if rev2:
340 if os.system('git-diff-tree -p %s %s %s %s'
341 % (rev1, rev2, files_str, extra_args)) != 0:
342 raise GitException, 'git-diff-tree failed'
343 else:
344 if os.system('git-diff-cache -p %s %s %s'
345 % (rev1, files_str, extra_args)) != 0:
346 raise GitException, 'git-diff-cache failed'
347
348 def diffstat(files = [], rev1 = 'HEAD', rev2 = None):
349 """Return the diffstat between rev1 and rev2
350 """
351 files_str = reduce(lambda x, y: x + ' ' + y, files, '')
352
353 os.system('git-update-cache --refresh > /dev/null')
354 ds_cmd = '| git-apply --stat'
355
356 if rev2:
357 f = os.popen('git-diff-tree -p %s %s %s %s'
358 % (rev1, rev2, files_str, ds_cmd), 'r')
359 str = f.read().rstrip()
360 if f.close():
361 raise GitException, 'git-diff-tree failed'
362 else:
363 f = os.popen('git-diff-cache -p %s %s %s'
364 % (rev1, files_str, ds_cmd), 'r')
365 str = f.read().rstrip()
366 if f.close():
367 raise GitException, 'git-diff-cache failed'
368
369 return str
370
371 def files(rev1, rev2):
372 """Return the files modified between rev1 and rev2
373 """
374 os.system('git-update-cache --refresh > /dev/null')
375
376 str = ''
377 f = os.popen('git-diff-tree -r %s %s' % (rev1, rev2),
378 'r')
379 for line in f:
380 str += '%s %s\n' % tuple(line.split()[4:])
381 if f.close():
382 raise GitException, 'git-diff-tree failed'
383
384 return str.rstrip()
385
386 def checkout(files = [], force = False):
387 """Check out the given or all files
388 """
389 git_flags = ''
390 if force:
391 git_flags += ' -f'
392 if len(files) == 0:
393 git_flags += ' -a'
394 else:
395 git_flags += reduce(lambda x, y: x + ' ' + y, files, ' --')
396
397 if os.system('git-checkout-cache -q -u%s' % git_flags) != 0:
398 raise GitException, 'Failed git-checkout-cache -q -u%s' % git_flags
399
400 def switch(tree_id):
401 """Switch the tree to the given id
402 """
403 to_delete = filter(lambda x: x[0] == 'N', __tree_status(tree_id = tree_id))
404
405 if os.system('git-read-tree -m %s' % tree_id) != 0:
406 raise GitException, 'Failed git-read-tree -m %s' % tree_id
407
408 checkout(force = True)
409 __set_head(tree_id)
410
411 # checkout doesn't remove files
412 for fs in to_delete:
413 os.remove(fs[1])