2 from datetime
import datetime
, timedelta
, tzinfo
4 from stgit
import exception
, run
, utils
5 from stgit
.config
import config
7 class RepositoryException(exception
.StgException
):
10 class DateException(exception
.StgException
):
11 def __init__(self
, string
, type):
12 exception
.StgException
.__init__(
13 self
, '"%s" is not a valid %s' %
(string
, type))
15 class DetachedHeadException(RepositoryException
):
17 RepositoryException
.__init__(self
, 'Not on any branch')
23 class NoValue(object):
26 def make_defaults(defaults
):
27 def d(val
, attr
, default_fun
= lambda: None):
30 elif defaults
!= NoValue
:
31 return getattr(defaults
, attr
)
36 class TimeZone(tzinfo
, Repr
):
37 def __init__(self
, tzstring
):
38 m
= re
.match(r
'^([+-])(\d{2}):?(\d{2})$', tzstring
)
40 raise DateException(tzstring
, 'time zone')
41 sign
= int(m
.group(1) + '1')
43 self
.__offset
= timedelta(hours
= sign
*int(m
.group(2)),
44 minutes
= sign
*int(m
.group(3)))
46 raise DateException(tzstring
, 'time zone')
47 self
.__name
= tzstring
48 def utcoffset(self
, dt
):
59 def __init__(self
, datestring
):
60 # Try git-formatted date.
61 m
= re
.match(r
'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring
)
64 self
.__time
= datetime
.fromtimestamp(int(m
.group(1)),
67 raise DateException(datestring
, 'date')
70 # Try iso-formatted date.
71 m
= re
.match(r
'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+'
72 + r
'([+-]\d\d:?\d\d)$', datestring
)
75 self
.__time
= datetime(
76 *[int(m
.group(i
+ 1)) for i
in xrange(6)],
77 **{'tzinfo': TimeZone(m
.group(7))})
79 raise DateException(datestring
, 'date')
82 raise DateException(datestring
, 'date')
84 return self
.isoformat()
86 """Human-friendly ISO 8601 format."""
87 return '%s %s' %
(self
.__time
.replace(tzinfo
= None).isoformat(' '),
90 def maybe(cls
, datestring
):
91 if datestring
in [None, NoValue
]:
93 return cls(datestring
)
97 def __init__(self
, name
= NoValue
, email
= NoValue
,
98 date
= NoValue
, defaults
= NoValue
):
99 d
= make_defaults(defaults
)
100 self
.__name
= d(name
, 'name')
101 self
.__email
= d(email
, 'email')
102 self
.__date
= d(date
, 'date')
103 assert isinstance(self
.__date
, Date
) or self
.__date
in [None, NoValue
]
104 name
= property(lambda self
: self
.__name
)
105 email
= property(lambda self
: self
.__email
)
106 date
= property(lambda self
: self
.__date
)
107 def set_name(self
, name
):
108 return type(self
)(name
= name
, defaults
= self
)
109 def set_email(self
, email
):
110 return type(self
)(email
= email
, defaults
= self
)
111 def set_date(self
, date
):
112 return type(self
)(date
= date
, defaults
= self
)
114 return '%s <%s> %s' %
(self
.name
, self
.email
, self
.date
)
117 m
= re
.match(r
'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s
)
119 name
= m
.group(1).strip()
121 date
= Date(m
.group(3))
122 return cls(name
, email
, date
)
125 if not hasattr(cls
, '__user'):
126 cls
.__user
= cls(name
= config
.get('user.name'),
127 email
= config
.get('user.email'))
131 if not hasattr(cls
, '__author'):
133 name
= os
.environ
.get('GIT_AUTHOR_NAME', NoValue
),
134 email
= os
.environ
.get('GIT_AUTHOR_EMAIL', NoValue
),
135 date
= Date
.maybe(os
.environ
.get('GIT_AUTHOR_DATE', NoValue
)),
136 defaults
= cls
.user())
140 if not hasattr(cls
, '__committer'):
141 cls
.__committer
= cls(
142 name
= os
.environ
.get('GIT_COMMITTER_NAME', NoValue
),
143 email
= os
.environ
.get('GIT_COMMITTER_EMAIL', NoValue
),
145 os
.environ
.get('GIT_COMMITTER_DATE', NoValue
)),
146 defaults
= cls
.user())
147 return cls
.__committer
151 def __init__(self
, sha1
):
153 sha1
= property(lambda self
: self
.__sha1
)
155 return 'Tree<%s>' % self
.sha1
157 class CommitData(Repr
):
159 def __init__(self
, tree
= NoValue
, parents
= NoValue
, author
= NoValue
,
160 committer
= NoValue
, message
= NoValue
, defaults
= NoValue
):
161 d
= make_defaults(defaults
)
162 self
.__tree
= d(tree
, 'tree')
163 self
.__parents
= d(parents
, 'parents')
164 self
.__author
= d(author
, 'author', Person
.author
)
165 self
.__committer
= d(committer
, 'committer', Person
.committer
)
166 self
.__message
= d(message
, 'message')
167 tree
= property(lambda self
: self
.__tree
)
168 parents
= property(lambda self
: self
.__parents
)
171 assert len(self
.__parents
) == 1
172 return self
.__parents
[0]
173 author
= property(lambda self
: self
.__author
)
174 committer
= property(lambda self
: self
.__committer
)
175 message
= property(lambda self
: self
.__message
)
176 def set_tree(self
, tree
):
177 return type(self
)(tree
= tree
, defaults
= self
)
178 def set_parents(self
, parents
):
179 return type(self
)(parents
= parents
, defaults
= self
)
180 def add_parent(self
, parent
):
181 return type(self
)(parents
= list(self
.parents
or []) + [parent
],
183 def set_parent(self
, parent
):
184 return self
.set_parents([parent
])
185 def set_author(self
, author
):
186 return type(self
)(author
= author
, defaults
= self
)
187 def set_committer(self
, committer
):
188 return type(self
)(committer
= committer
, defaults
= self
)
189 def set_message(self
, message
):
190 return type(self
)(message
= message
, defaults
= self
)
191 def is_nochange(self
):
192 return len(self
.parents
) == 1 and self
.tree
== self
.parent
.data
.tree
194 if self
.tree
== None:
197 tree
= self
.tree
.sha1
198 if self
.parents
== None:
201 parents
= [p
.sha1
for p
in self
.parents
]
202 return ('CommitData<tree: %s, parents: %s, author: %s,'
203 ' committer: %s, message: "%s">'
204 ) %
(tree
, parents
, self
.author
, self
.committer
, self
.message
)
206 def parse(cls
, repository
, s
):
207 cd
= cls(parents
= [])
208 lines
= list(s
.splitlines(True))
209 for i
in xrange(len(lines
)):
210 line
= lines
[i
].strip()
212 return cd
.set_message(''.join(lines
[i
+1:]))
213 key
, value
= line
.split(None, 1)
215 cd
= cd
.set_tree(repository
.get_tree(value
))
216 elif key
== 'parent':
217 cd
= cd
.add_parent(repository
.get_commit(value
))
218 elif key
== 'author':
219 cd
= cd
.set_author(Person
.parse(value
))
220 elif key
== 'committer':
221 cd
= cd
.set_committer(Person
.parse(value
))
228 def __init__(self
, repository
, sha1
):
230 self
.__repository
= repository
232 sha1
= property(lambda self
: self
.__sha1
)
235 if self
.__data
== None:
236 self
.__data
= CommitData
.parse(
238 self
.__repository
.cat_object(self
.sha1
))
241 return 'Commit<sha1: %s, data: %s>' %
(self
.sha1
, self
.__data
)
244 def __init__(self
, repository
):
245 self
.__repository
= repository
247 def __cache_refs(self
):
249 for line
in self
.__repository
.run(['git', 'show-ref']).output_lines():
250 m
= re
.match(r
'^([0-9a-f]{40})\s+(\S+)$', line
)
251 sha1
, ref
= m
.groups()
252 self
.__refs
[ref
] = sha1
254 """Throws KeyError if ref doesn't exist."""
255 if self
.__refs
== None:
257 return self
.__repository
.get_commit(self
.__refs
[ref
])
258 def exists(self
, ref
):
265 def set(self
, ref
, commit
, msg
):
266 if self
.__refs
== None:
268 old_sha1
= self
.__refs
.get(ref
, '0'*40)
269 new_sha1
= commit
.sha1
270 if old_sha1
!= new_sha1
:
271 self
.__repository
.run(['git', 'update-ref', '-m', msg
,
272 ref
, new_sha1
, old_sha1
]).no_output()
273 self
.__refs
[ref
] = new_sha1
274 def delete(self
, ref
):
275 if self
.__refs
== None:
277 self
.__repository
.run(['git', 'update-ref',
278 '-d', ref
, self
.__refs
[ref
]]).no_output()
281 class ObjectCache(object):
282 """Cache for Python objects, for making sure that we create only one
283 Python object per git object."""
284 def __init__(self
, create
):
286 self
.__create
= create
287 def __getitem__(self
, name
):
288 if not name
in self
.__objects
:
289 self
.__objects
[name
] = self
.__create(name
)
290 return self
.__objects
[name
]
291 def __contains__(self
, name
):
292 return name
in self
.__objects
293 def __setitem__(self
, name
, val
):
294 assert not name
in self
.__objects
295 self
.__objects
[name
] = val
297 class RunWithEnv(object):
298 def run(self
, args
, env
= {}):
299 return run
.Run(*args
).env(utils
.add_dict(self
.env
, env
))
301 class RunWithEnvCwd(RunWithEnv
):
302 def run(self
, args
, env
= {}):
303 return RunWithEnv
.run(self
, args
, env
).cwd(self
.cwd
)
305 class Repository(RunWithEnv
):
306 def __init__(self
, directory
):
307 self
.__git_dir
= directory
308 self
.__refs
= Refs(self
)
309 self
.__trees
= ObjectCache(lambda sha1
: Tree(sha1
))
310 self
.__commits
= ObjectCache(lambda sha1
: Commit(self
, sha1
))
311 self
.__default_index
= None
312 self
.__default_worktree
= None
313 self
.__default_iw
= None
314 env
= property(lambda self
: { 'GIT_DIR': self
.__git_dir
})
317 """Return the default repository."""
319 return cls(run
.Run('git', 'rev-parse', '--git-dir'
321 except run
.RunException
:
322 raise RepositoryException('Cannot find git repository')
324 def default_index(self
):
325 if self
.__default_index
== None:
326 self
.__default_index
= Index(
327 self
, (os
.environ
.get('GIT_INDEX_FILE', None)
328 or os
.path
.join(self
.__git_dir
, 'index')))
329 return self
.__default_index
330 def temp_index(self
):
331 return Index(self
, self
.__git_dir
)
333 def default_worktree(self
):
334 if self
.__default_worktree
== None:
335 path
= os
.environ
.get('GIT_WORK_TREE', None)
337 o
= run
.Run('git', 'rev-parse', '--show-cdup').output_lines()
341 self
.__default_worktree
= Worktree(path
)
342 return self
.__default_worktree
344 def default_iw(self
):
345 if self
.__default_iw
== None:
346 self
.__default_iw
= IndexAndWorktree(self
.default_index
,
347 self
.default_worktree
)
348 return self
.__default_iw
349 directory
= property(lambda self
: self
.__git_dir
)
350 refs
= property(lambda self
: self
.__refs
)
351 def cat_object(self
, sha1
):
352 return self
.run(['git', 'cat-file', '-p', sha1
]).raw_output()
353 def rev_parse(self
, rev
):
355 return self
.get_commit(self
.run(
356 ['git', 'rev-parse', '%s^{commit}' % rev
]
358 except run
.RunException
:
359 raise RepositoryException('%s: No such revision' % rev
)
360 def get_tree(self
, sha1
):
361 return self
.__trees
[sha1
]
362 def get_commit(self
, sha1
):
363 return self
.__commits
[sha1
]
364 def commit(self
, commitdata
):
365 c
= ['git', 'commit-tree', commitdata
.tree
.sha1
]
366 for p
in commitdata
.parents
:
370 for p
, v1
in ((commitdata
.author
, 'AUTHOR'),
371 (commitdata
.committer
, 'COMMITTER')):
373 for attr
, v2
in (('name', 'NAME'), ('email', 'EMAIL'),
375 if getattr(p
, attr
) != None:
376 env
['GIT_%s_%s' %
(v1
, v2
)] = str(getattr(p
, attr
))
377 sha1
= self
.run(c
, env
= env
).raw_input(commitdata
.message
379 return self
.get_commit(sha1
)
383 return self
.run(['git', 'symbolic-ref', '-q', 'HEAD']
385 except run
.RunException
:
386 raise DetachedHeadException()
387 def set_head(self
, ref
, msg
):
388 self
.run(['git', 'symbolic-ref', '-m', msg
, 'HEAD', ref
]).no_output()
389 def simple_merge(self
, base
, ours
, theirs
):
390 """Given three trees, tries to do an in-index merge in a temporary
391 index with a temporary index. Returns the result tree, or None if
392 the merge failed (due to conflicts)."""
393 assert isinstance(base
, Tree
)
394 assert isinstance(ours
, Tree
)
395 assert isinstance(theirs
, Tree
)
397 # Take care of the really trivial cases.
405 index
= self
.temp_index()
407 index
.merge(base
, ours
, theirs
)
409 return index
.write_tree()
410 except MergeException
:
414 def apply(self
, tree
, patch_text
):
415 """Given a tree and a patch, will either return the new tree that
416 results when the patch is applied, or None if the patch
417 couldn't be applied."""
418 assert isinstance(tree
, Tree
)
421 index
= self
.temp_index()
423 index
.read_tree(tree
)
425 index
.apply(patch_text
)
426 return index
.write_tree()
427 except MergeException
:
431 def diff_tree(self
, t1
, t2
, diff_opts
):
432 assert isinstance(t1
, Tree
)
433 assert isinstance(t2
, Tree
)
434 return self
.run(['git', 'diff-tree', '-p'] + list(diff_opts
)
435 + [t1
.sha1
, t2
.sha1
]).raw_output()
437 class MergeException(exception
.StgException
):
440 class MergeConflictException(MergeException
):
443 class Index(RunWithEnv
):
444 def __init__(self
, repository
, filename
):
445 self
.__repository
= repository
446 if os
.path
.isdir(filename
):
447 # Create a temp index in the given directory.
448 self
.__filename
= os
.path
.join(
449 filename
, 'index.temp-%d-%x' %
(os
.getpid(), id(self
)))
452 self
.__filename
= filename
453 env
= property(lambda self
: utils
.add_dict(
454 self
.__repository
.env
, { 'GIT_INDEX_FILE': self
.__filename
}))
455 def read_tree(self
, tree
):
456 self
.run(['git', 'read-tree', tree
.sha1
]).no_output()
457 def write_tree(self
):
459 return self
.__repository
.get_tree(
460 self
.run(['git', 'write-tree']).discard_stderr(
462 except run
.RunException
:
463 raise MergeException('Conflicting merge')
466 self
.run(['git', 'update-index', '--refresh']).discard_output()
467 except run
.RunException
:
471 def merge(self
, base
, ours
, theirs
):
472 """In-index merge, no worktree involved."""
473 self
.run(['git', 'read-tree', '-m', '-i', '--aggressive',
474 base
.sha1
, ours
.sha1
, theirs
.sha1
]).no_output()
475 def apply(self
, patch_text
):
476 """In-index patch application, no worktree involved."""
478 self
.run(['git', 'apply', '--cached']
479 ).raw_input(patch_text
).no_output()
480 except run
.RunException
:
481 raise MergeException('Patch does not apply cleanly')
483 if os
.path
.isfile(self
.__filename
):
484 os
.remove(self
.__filename
)
486 """The set of conflicting paths."""
488 for line
in self
.run(['git', 'ls-files', '-z', '--unmerged']
489 ).raw_output().split('\0')[:-1]:
490 stat
, path
= line
.split('\t', 1)
494 class Worktree(object):
495 def __init__(self
, directory
):
496 self
.__directory
= directory
497 env
= property(lambda self
: { 'GIT_WORK_TREE': '.' })
498 directory
= property(lambda self
: self
.__directory
)
500 class CheckoutException(exception
.StgException
):
503 class IndexAndWorktree(RunWithEnvCwd
):
504 def __init__(self
, index
, worktree
):
506 self
.__worktree
= worktree
507 index
= property(lambda self
: self
.__index
)
508 env
= property(lambda self
: utils
.add_dict(self
.__index
.env
,
509 self
.__worktree
.env
))
510 cwd
= property(lambda self
: self
.__worktree
.directory
)
511 def checkout(self
, old_tree
, new_tree
):
512 # TODO: Optionally do a 3-way instead of doing nothing when we
513 # have a problem. Or maybe we should stash changes in a patch?
514 assert isinstance(old_tree
, Tree
)
515 assert isinstance(new_tree
, Tree
)
517 self
.run(['git', 'read-tree', '-u', '-m',
518 '--exclude-per-directory=.gitignore',
519 old_tree
.sha1
, new_tree
.sha1
]
521 except run
.RunException
:
522 raise CheckoutException('Index/workdir dirty')
523 def merge(self
, base
, ours
, theirs
):
524 assert isinstance(base
, Tree
)
525 assert isinstance(ours
, Tree
)
526 assert isinstance(theirs
, Tree
)
528 r
= self
.run(['git', 'merge-recursive', base
.sha1
, '--', ours
.sha1
,
530 env
= { 'GITHEAD_%s' % base
.sha1
: 'ancestor',
531 'GITHEAD_%s' % ours
.sha1
: 'current',
532 'GITHEAD_%s' % theirs
.sha1
: 'patched'})
534 except run
.RunException
, e
:
536 raise MergeConflictException()
538 raise MergeException('Index/worktree dirty')
539 def changed_files(self
):
540 return self
.run(['git', 'diff-files', '--name-only']).output_lines()
541 def update_index(self
, files
):
542 self
.run(['git', 'update-index', '--remove', '-z', '--stdin']
543 ).input_nulterm(files
).discard_output()