2af1844e4c887ea079fb8393c661f8f8650b0c4d
[stgit] / stgit / lib / git.py
1 import os, os.path, re
2 from stgit import exception, run, utils
3
4 class RepositoryException(exception.StgException):
5 pass
6
7 class DetachedHeadException(RepositoryException):
8 def __init__(self):
9 RepositoryException.__init__(self, 'Not on any branch')
10
11 class Repr(object):
12 def __repr__(self):
13 return str(self)
14
15 class NoValue(object):
16 pass
17
18 def make_defaults(defaults):
19 def d(val, attr):
20 if val != NoValue:
21 return val
22 elif defaults != NoValue:
23 return getattr(defaults, attr)
24 else:
25 return None
26 return d
27
28 class Person(Repr):
29 """Immutable."""
30 def __init__(self, name = NoValue, email = NoValue,
31 date = NoValue, defaults = NoValue):
32 d = make_defaults(defaults)
33 self.__name = d(name, 'name')
34 self.__email = d(email, 'email')
35 self.__date = d(date, 'date')
36 name = property(lambda self: self.__name)
37 email = property(lambda self: self.__email)
38 date = property(lambda self: self.__date)
39 def set_name(self, name):
40 return type(self)(name = name, defaults = self)
41 def set_email(self, email):
42 return type(self)(email = email, defaults = self)
43 def set_date(self, date):
44 return type(self)(date = date, defaults = self)
45 def __str__(self):
46 return '%s <%s> %s' % (self.name, self.email, self.date)
47 @classmethod
48 def parse(cls, s):
49 m = re.match(r'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s)
50 assert m
51 name = m.group(1).strip()
52 email = m.group(2)
53 date = m.group(3)
54 return cls(name, email, date)
55
56 class Tree(Repr):
57 """Immutable."""
58 def __init__(self, sha1):
59 self.__sha1 = sha1
60 sha1 = property(lambda self: self.__sha1)
61 def __str__(self):
62 return 'Tree<%s>' % self.sha1
63
64 class Commitdata(Repr):
65 """Immutable."""
66 def __init__(self, tree = NoValue, parents = NoValue, author = NoValue,
67 committer = NoValue, message = NoValue, defaults = NoValue):
68 d = make_defaults(defaults)
69 self.__tree = d(tree, 'tree')
70 self.__parents = d(parents, 'parents')
71 self.__author = d(author, 'author')
72 self.__committer = d(committer, 'committer')
73 self.__message = d(message, 'message')
74 tree = property(lambda self: self.__tree)
75 parents = property(lambda self: self.__parents)
76 @property
77 def parent(self):
78 assert len(self.__parents) == 1
79 return self.__parents[0]
80 author = property(lambda self: self.__author)
81 committer = property(lambda self: self.__committer)
82 message = property(lambda self: self.__message)
83 def set_tree(self, tree):
84 return type(self)(tree = tree, defaults = self)
85 def set_parents(self, parents):
86 return type(self)(parents = parents, defaults = self)
87 def add_parent(self, parent):
88 return type(self)(parents = list(self.parents or []) + [parent],
89 defaults = self)
90 def set_parent(self, parent):
91 return self.set_parents([parent])
92 def set_author(self, author):
93 return type(self)(author = author, defaults = self)
94 def set_committer(self, committer):
95 return type(self)(committer = committer, defaults = self)
96 def set_message(self, message):
97 return type(self)(message = message, defaults = self)
98 def is_nochange(self):
99 return len(self.parents) == 1 and self.tree == self.parent.data.tree
100 def __str__(self):
101 if self.tree == None:
102 tree = None
103 else:
104 tree = self.tree.sha1
105 if self.parents == None:
106 parents = None
107 else:
108 parents = [p.sha1 for p in self.parents]
109 return ('Commitdata<tree: %s, parents: %s, author: %s,'
110 ' committer: %s, message: "%s">'
111 ) % (tree, parents, self.author, self.committer, self.message)
112 @classmethod
113 def parse(cls, repository, s):
114 cd = cls()
115 lines = list(s.splitlines(True))
116 for i in xrange(len(lines)):
117 line = lines[i].strip()
118 if not line:
119 return cd.set_message(''.join(lines[i+1:]))
120 key, value = line.split(None, 1)
121 if key == 'tree':
122 cd = cd.set_tree(repository.get_tree(value))
123 elif key == 'parent':
124 cd = cd.add_parent(repository.get_commit(value))
125 elif key == 'author':
126 cd = cd.set_author(Person.parse(value))
127 elif key == 'committer':
128 cd = cd.set_committer(Person.parse(value))
129 else:
130 assert False
131 assert False
132
133 class Commit(Repr):
134 """Immutable."""
135 def __init__(self, repository, sha1):
136 self.__sha1 = sha1
137 self.__repository = repository
138 self.__data = None
139 sha1 = property(lambda self: self.__sha1)
140 @property
141 def data(self):
142 if self.__data == None:
143 self.__data = Commitdata.parse(
144 self.__repository,
145 self.__repository.cat_object(self.sha1))
146 return self.__data
147 def __str__(self):
148 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
149
150 class Refs(object):
151 def __init__(self, repository):
152 self.__repository = repository
153 self.__refs = None
154 def __cache_refs(self):
155 self.__refs = {}
156 for line in self.__repository.run(['git', 'show-ref']).output_lines():
157 m = re.match(r'^([0-9a-f]{40})\s+(\S+)$', line)
158 sha1, ref = m.groups()
159 self.__refs[ref] = sha1
160 def get(self, ref):
161 """Throws KeyError if ref doesn't exist."""
162 if self.__refs == None:
163 self.__cache_refs()
164 return self.__repository.get_commit(self.__refs[ref])
165 def exists(self, ref):
166 try:
167 self.get(ref)
168 except KeyError:
169 return False
170 else:
171 return True
172 def set(self, ref, commit, msg):
173 if self.__refs == None:
174 self.__cache_refs()
175 old_sha1 = self.__refs.get(ref, '0'*40)
176 new_sha1 = commit.sha1
177 if old_sha1 != new_sha1:
178 self.__repository.run(['git', 'update-ref', '-m', msg,
179 ref, new_sha1, old_sha1]).no_output()
180 self.__refs[ref] = new_sha1
181 def delete(self, ref):
182 if self.__refs == None:
183 self.__cache_refs()
184 self.__repository.run(['git', 'update-ref',
185 '-d', ref, self.__refs[ref]]).no_output()
186 del self.__refs[ref]
187
188 class ObjectCache(object):
189 """Cache for Python objects, for making sure that we create only one
190 Python object per git object."""
191 def __init__(self, create):
192 self.__objects = {}
193 self.__create = create
194 def __getitem__(self, name):
195 if not name in self.__objects:
196 self.__objects[name] = self.__create(name)
197 return self.__objects[name]
198 def __contains__(self, name):
199 return name in self.__objects
200 def __setitem__(self, name, val):
201 assert not name in self.__objects
202 self.__objects[name] = val
203
204 class RunWithEnv(object):
205 def run(self, args, env = {}):
206 return run.Run(*args).env(utils.add_dict(self.env, env))
207
208 class Repository(RunWithEnv):
209 def __init__(self, directory):
210 self.__git_dir = directory
211 self.__refs = Refs(self)
212 self.__trees = ObjectCache(lambda sha1: Tree(sha1))
213 self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
214 self.__default_index = None
215 self.__default_worktree = None
216 self.__default_iw = None
217 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
218 @classmethod
219 def default(cls):
220 """Return the default repository."""
221 try:
222 return cls(run.Run('git', 'rev-parse', '--git-dir'
223 ).output_one_line())
224 except run.RunException:
225 raise RepositoryException('Cannot find git repository')
226 @property
227 def default_index(self):
228 if self.__default_index == None:
229 self.__default_index = Index(
230 self, (os.environ.get('GIT_INDEX_FILE', None)
231 or os.path.join(self.__git_dir, 'index')))
232 return self.__default_index
233 def temp_index(self):
234 return Index(self, self.__git_dir)
235 @property
236 def default_worktree(self):
237 if self.__default_worktree == None:
238 path = os.environ.get('GIT_WORK_TREE', None)
239 if not path:
240 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
241 o = o or ['.']
242 assert len(o) == 1
243 path = o[0]
244 self.__default_worktree = Worktree(path)
245 return self.__default_worktree
246 @property
247 def default_iw(self):
248 if self.__default_iw == None:
249 self.__default_iw = IndexAndWorktree(self.default_index,
250 self.default_worktree)
251 return self.__default_iw
252 directory = property(lambda self: self.__git_dir)
253 refs = property(lambda self: self.__refs)
254 def cat_object(self, sha1):
255 return self.run(['git', 'cat-file', '-p', sha1]).raw_output()
256 def rev_parse(self, rev):
257 try:
258 return self.get_commit(self.run(
259 ['git', 'rev-parse', '%s^{commit}' % rev]
260 ).output_one_line())
261 except run.RunException:
262 raise RepositoryException('%s: No such revision' % rev)
263 def get_tree(self, sha1):
264 return self.__trees[sha1]
265 def get_commit(self, sha1):
266 return self.__commits[sha1]
267 def commit(self, commitdata):
268 c = ['git', 'commit-tree', commitdata.tree.sha1]
269 for p in commitdata.parents:
270 c.append('-p')
271 c.append(p.sha1)
272 env = {}
273 for p, v1 in ((commitdata.author, 'AUTHOR'),
274 (commitdata.committer, 'COMMITTER')):
275 if p != None:
276 for attr, v2 in (('name', 'NAME'), ('email', 'EMAIL'),
277 ('date', 'DATE')):
278 if getattr(p, attr) != None:
279 env['GIT_%s_%s' % (v1, v2)] = getattr(p, attr)
280 sha1 = self.run(c, env = env).raw_input(commitdata.message
281 ).output_one_line()
282 return self.get_commit(sha1)
283 @property
284 def head(self):
285 try:
286 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
287 ).output_one_line()
288 except run.RunException:
289 raise DetachedHeadException()
290 def set_head(self, ref, msg):
291 self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
292 def simple_merge(self, base, ours, theirs):
293 """Given three trees, tries to do an in-index merge in a temporary
294 index with a temporary index. Returns the result tree, or None if
295 the merge failed (due to conflicts)."""
296 assert isinstance(base, Tree)
297 assert isinstance(ours, Tree)
298 assert isinstance(theirs, Tree)
299
300 # Take care of the really trivial cases.
301 if base == ours:
302 return theirs
303 if base == theirs:
304 return ours
305 if ours == theirs:
306 return ours
307
308 index = self.temp_index()
309 try:
310 index.merge(base, ours, theirs)
311 try:
312 return index.write_tree()
313 except MergeException:
314 return None
315 finally:
316 index.delete()
317
318 class MergeException(exception.StgException):
319 pass
320
321 class Index(RunWithEnv):
322 def __init__(self, repository, filename):
323 self.__repository = repository
324 if os.path.isdir(filename):
325 # Create a temp index in the given directory.
326 self.__filename = os.path.join(
327 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
328 self.delete()
329 else:
330 self.__filename = filename
331 env = property(lambda self: utils.add_dict(
332 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
333 def read_tree(self, tree):
334 self.run(['git', 'read-tree', tree.sha1]).no_output()
335 def write_tree(self):
336 try:
337 return self.__repository.get_tree(
338 self.run(['git', 'write-tree']).discard_stderr(
339 ).output_one_line())
340 except run.RunException:
341 raise MergeException('Conflicting merge')
342 def is_clean(self):
343 try:
344 self.run(['git', 'update-index', '--refresh']).discard_output()
345 except run.RunException:
346 return False
347 else:
348 return True
349 def merge(self, base, ours, theirs):
350 """In-index merge, no worktree involved."""
351 self.run(['git', 'read-tree', '-m', '-i', '--aggressive',
352 base.sha1, ours.sha1, theirs.sha1]).no_output()
353 def delete(self):
354 if os.path.isfile(self.__filename):
355 os.remove(self.__filename)
356
357 class Worktree(object):
358 def __init__(self, directory):
359 self.__directory = directory
360 env = property(lambda self: { 'GIT_WORK_TREE': self.__directory })
361 directory = property(lambda self: self.__directory)
362
363 class CheckoutException(exception.StgException):
364 pass
365
366 class IndexAndWorktree(RunWithEnv):
367 def __init__(self, index, worktree):
368 self.__index = index
369 self.__worktree = worktree
370 index = property(lambda self: self.__index)
371 env = property(lambda self: utils.add_dict(self.__index.env,
372 self.__worktree.env))
373 def checkout(self, old_tree, new_tree):
374 # TODO: Optionally do a 3-way instead of doing nothing when we
375 # have a problem. Or maybe we should stash changes in a patch?
376 assert isinstance(old_tree, Tree)
377 assert isinstance(new_tree, Tree)
378 try:
379 self.run(['git', 'read-tree', '-u', '-m',
380 '--exclude-per-directory=.gitignore',
381 old_tree.sha1, new_tree.sha1]
382 ).cwd(self.__worktree.directory).discard_output()
383 except run.RunException:
384 raise CheckoutException('Index/workdir dirty')
385 def merge(self, base, ours, theirs):
386 assert isinstance(base, Tree)
387 assert isinstance(ours, Tree)
388 assert isinstance(theirs, Tree)
389 try:
390 self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
391 theirs.sha1],
392 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
393 'GITHEAD_%s' % ours.sha1: 'current',
394 'GITHEAD_%s' % theirs.sha1: 'patched'}
395 ).cwd(self.__worktree.directory).discard_output()
396 except run.RunException, e:
397 raise MergeException('Index/worktree dirty')
398 def changed_files(self):
399 return self.run(['git', 'diff-files', '--name-only']).output_lines()
400 def update_index(self, files):
401 self.run(['git', 'update-index', '--remove', '-z', '--stdin']
402 ).input_nulterm(files).discard_output()