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