1 """A Python class hierarchy wrapping a git repository and its
4 import atexit
, os
, os
.path
, re
, signal
5 from datetime
import datetime
, timedelta
, tzinfo
7 from stgit
import exception
, run
, utils
8 from stgit
.config
import config
10 class Immutable(object):
11 """I{Immutable} objects cannot be modified once created. Any
12 modification methods will return a new object, leaving the
13 original object as it was.
15 The reason for this is that we want to be able to represent git
16 objects, which are immutable, and want to be able to create new
17 git objects that are just slight modifications of other git
18 objects. (Such as, for example, modifying the commit message of a
19 commit object while leaving the rest of it intact. This involves
20 creating a whole new commit object that's exactly like the old one
21 except for the commit message.)
23 The L{Immutable} class doesn't actually enforce immutability --
24 that is up to the individual immutable subclasses. It just serves
27 class RepositoryException(exception
.StgException
):
28 """Base class for all exceptions due to failed L{Repository}
31 class BranchException(exception
.StgException
):
32 """Exception raised by failed L{Branch} operations."""
34 class DateException(exception
.StgException
):
35 """Exception raised when a date+time string could not be parsed."""
36 def __init__(self
, string
, type):
37 exception
.StgException
.__init__(
38 self
, '"%s" is not a valid %s' %
(string
, type))
40 class DetachedHeadException(RepositoryException
):
41 """Exception raised when HEAD is detached (that is, there is no
44 RepositoryException
.__init__(self
, 'Not on any branch')
47 """Utility class that defines C{__reps__} in terms of C{__str__}."""
51 class NoValue(object):
52 """A handy default value that is guaranteed to be distinct from any
53 real argument value."""
56 def make_defaults(defaults
):
57 def d(val
, attr
, default_fun
= lambda: None):
60 elif defaults
!= NoValue
:
61 return getattr(defaults
, attr
)
66 class TimeZone(tzinfo
, Repr
):
67 """A simple time zone class for static offsets from UTC. (We have to
68 define our own since Python's standard library doesn't define any
69 time zone classes.)"""
70 def __init__(self
, tzstring
):
71 m
= re
.match(r
'^([+-])(\d{2}):?(\d{2})$', tzstring
)
73 raise DateException(tzstring
, 'time zone')
74 sign
= int(m
.group(1) + '1')
76 self
.__offset
= timedelta(hours
= sign
*int(m
.group(2)),
77 minutes
= sign
*int(m
.group(3)))
79 raise DateException(tzstring
, 'time zone')
80 self
.__name
= tzstring
81 def utcoffset(self
, dt
):
90 class Date(Immutable
, Repr
):
91 """Represents a timestamp used in git commits."""
92 def __init__(self
, datestring
):
93 # Try git-formatted date.
94 m
= re
.match(r
'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring
)
97 self
.__time
= datetime
.fromtimestamp(int(m
.group(1)),
100 raise DateException(datestring
, 'date')
103 # Try iso-formatted date.
104 m
= re
.match(r
'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+'
105 + r
'([+-]\d\d:?\d\d)$', datestring
)
108 self
.__time
= datetime(
109 *[int(m
.group(i
+ 1)) for i
in xrange(6)],
110 **{'tzinfo': TimeZone(m
.group(7))})
112 raise DateException(datestring
, 'date')
115 raise DateException(datestring
, 'date')
117 return self
.isoformat()
119 """Human-friendly ISO 8601 format."""
120 return '%s %s' %
(self
.__time
.replace(tzinfo
= None).isoformat(' '),
123 def maybe(cls
, datestring
):
124 """Return a new object initialized with the argument if it contains a
125 value (otherwise, just return the argument)."""
126 if datestring
in [None, NoValue
]:
128 return cls(datestring
)
130 class Person(Immutable
, Repr
):
131 """Represents an author or committer in a git commit object. Contains
132 name, email and timestamp."""
133 def __init__(self
, name
= NoValue
, email
= NoValue
,
134 date
= NoValue
, defaults
= NoValue
):
135 d
= make_defaults(defaults
)
136 self
.__name
= d(name
, 'name')
137 self
.__email
= d(email
, 'email')
138 self
.__date
= d(date
, 'date')
139 assert isinstance(self
.__date
, Date
) or self
.__date
in [None, NoValue
]
140 name
= property(lambda self
: self
.__name
)
141 email
= property(lambda self
: self
.__email
)
142 name_email
= property(lambda self
: '%s <%s>' %
(self
.name
, self
.email
))
143 date
= property(lambda self
: self
.__date
)
144 def set_name(self
, name
):
145 return type(self
)(name
= name
, defaults
= self
)
146 def set_email(self
, email
):
147 return type(self
)(email
= email
, defaults
= self
)
148 def set_date(self
, date
):
149 return type(self
)(date
= date
, defaults
= self
)
151 return '%s %s' %
(self
.name_email
, self
.date
)
154 m
= re
.match(r
'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s
)
156 name
= m
.group(1).strip()
158 date
= Date(m
.group(3))
159 return cls(name
, email
, date
)
162 if not hasattr(cls
, '__user'):
163 cls
.__user
= cls(name
= config
.get('user.name'),
164 email
= config
.get('user.email'))
168 if not hasattr(cls
, '__author'):
170 name
= os
.environ
.get('GIT_AUTHOR_NAME', NoValue
),
171 email
= os
.environ
.get('GIT_AUTHOR_EMAIL', NoValue
),
172 date
= Date
.maybe(os
.environ
.get('GIT_AUTHOR_DATE', NoValue
)),
173 defaults
= cls
.user())
177 if not hasattr(cls
, '__committer'):
178 cls
.__committer
= cls(
179 name
= os
.environ
.get('GIT_COMMITTER_NAME', NoValue
),
180 email
= os
.environ
.get('GIT_COMMITTER_EMAIL', NoValue
),
182 os
.environ
.get('GIT_COMMITTER_DATE', NoValue
)),
183 defaults
= cls
.user())
184 return cls
.__committer
186 class GitObject(Immutable
, Repr
):
187 """Base class for all git objects. One git object is represented by at
188 most one C{GitObject}, which makes it possible to compare them
189 using normal Python object comparison; it also ensures we don't
190 waste more memory than necessary."""
192 class BlobData(Immutable
, Repr
):
193 """Represents the data contents of a git blob object."""
194 def __init__(self
, string
):
195 self
.__string
= str(string
)
196 str = property(lambda self
: self
.__string
)
197 def commit(self
, repository
):
199 @return: The committed blob
201 sha1
= repository
.run(['git', 'hash-object', '-w', '--stdin']
202 ).raw_input(self
.str).output_one_line()
203 return repository
.get_blob(sha1
)
205 class Blob(GitObject
):
206 """Represents a git blob object. All the actual data contents of the
207 blob object is stored in the L{data} member, which is a
208 L{BlobData} object."""
210 default_perm
= '100644'
211 def __init__(self
, repository
, sha1
):
212 self
.__repository
= repository
214 sha1
= property(lambda self
: self
.__sha1
)
216 return 'Blob<%s>' % self
.sha1
219 return BlobData(self
.__repository
.cat_object(self
.sha1
))
221 class ImmutableDict(dict):
222 """A dictionary that cannot be modified once it's been created."""
223 def error(*args
, **kwargs
):
224 raise TypeError('Cannot modify immutable dict')
233 class TreeData(Immutable
, Repr
):
234 """Represents the data contents of a git tree object."""
237 if isinstance(po
, GitObject
):
238 perm
, object = po
.default_perm
, po
242 def __init__(self
, entries
):
243 """Create a new L{TreeData} object from the given mapping from names
244 (strings) to either (I{permission}, I{object}) tuples or just
246 self
.__entries
= ImmutableDict((name
, self
.__x(po
))
247 for (name
, po
) in entries
.iteritems())
248 entries
= property(lambda self
: self
.__entries
)
249 """Map from name to (I{permission}, I{object}) tuple."""
250 def set_entry(self
, name
, po
):
251 """Create a new L{TreeData} object identical to this one, except that
252 it maps C{name} to C{po}.
254 @param name: Name of the changed mapping
256 @param po: Value of the changed mapping
257 @type po: L{Blob} or L{Tree} or (C{str}, L{Blob} or L{Tree})
258 @return: The new L{TreeData} object
259 @rtype: L{TreeData}"""
260 e
= dict(self
.entries
)
261 e
[name
] = self
.__x(po
)
263 def del_entry(self
, name
):
264 """Create a new L{TreeData} object identical to this one, except that
265 it doesn't map C{name} to anything.
267 @param name: Name of the deleted mapping
269 @return: The new L{TreeData} object
270 @rtype: L{TreeData}"""
271 e
= dict(self
.entries
)
274 def commit(self
, repository
):
276 @return: The committed tree
279 '%s %s %s\t%s\0' %
(mode
, obj
.typename
, obj
.sha1
, name
)
280 for (name
, (mode
, obj
)) in self
.entries
.iteritems())
281 sha1
= repository
.run(['git', 'mktree', '-z']
282 ).raw_input(listing
).output_one_line()
283 return repository
.get_tree(sha1
)
285 def parse(cls
, repository
, s
):
286 """Parse a raw git tree description.
288 @return: A new L{TreeData} object
289 @rtype: L{TreeData}"""
291 for line
in s
.split('\0')[:-1]:
292 m
= re
.match(r
'^([0-7]{6}) ([a-z]+) ([0-9a-f]{40})\t(.*)$', line
)
294 perm
, type, sha1
, name
= m
.groups()
295 entries
[name
] = (perm
, repository
.get_object(type, sha1
))
298 class Tree(GitObject
):
299 """Represents a git tree object. All the actual data contents of the
300 tree object is stored in the L{data} member, which is a
301 L{TreeData} object."""
303 default_perm
= '040000'
304 def __init__(self
, repository
, sha1
):
306 self
.__repository
= repository
308 sha1
= property(lambda self
: self
.__sha1
)
311 if self
.__data
== None:
312 self
.__data
= TreeData
.parse(
314 self
.__repository
.run(['git', 'ls-tree', '-z', self
.sha1
]
318 return 'Tree<sha1: %s>' % self
.sha1
320 class CommitData(Immutable
, Repr
):
321 """Represents the data contents of a git commit object."""
322 def __init__(self
, tree
= NoValue
, parents
= NoValue
, author
= NoValue
,
323 committer
= NoValue
, message
= NoValue
, defaults
= NoValue
):
324 d
= make_defaults(defaults
)
325 self
.__tree
= d(tree
, 'tree')
326 self
.__parents
= d(parents
, 'parents')
327 self
.__author
= d(author
, 'author', Person
.author
)
328 self
.__committer
= d(committer
, 'committer', Person
.committer
)
329 self
.__message
= d(message
, 'message')
330 tree
= property(lambda self
: self
.__tree
)
331 parents
= property(lambda self
: self
.__parents
)
334 assert len(self
.__parents
) == 1
335 return self
.__parents
[0]
336 author
= property(lambda self
: self
.__author
)
337 committer
= property(lambda self
: self
.__committer
)
338 message
= property(lambda self
: self
.__message
)
339 def set_tree(self
, tree
):
340 return type(self
)(tree
= tree
, defaults
= self
)
341 def set_parents(self
, parents
):
342 return type(self
)(parents
= parents
, defaults
= self
)
343 def add_parent(self
, parent
):
344 return type(self
)(parents
= list(self
.parents
or []) + [parent
],
346 def set_parent(self
, parent
):
347 return self
.set_parents([parent
])
348 def set_author(self
, author
):
349 return type(self
)(author
= author
, defaults
= self
)
350 def set_committer(self
, committer
):
351 return type(self
)(committer
= committer
, defaults
= self
)
352 def set_message(self
, message
):
353 return type(self
)(message
= message
, defaults
= self
)
354 def is_nochange(self
):
355 return len(self
.parents
) == 1 and self
.tree
== self
.parent
.data
.tree
357 if self
.tree
== None:
360 tree
= self
.tree
.sha1
361 if self
.parents
== None:
364 parents
= [p
.sha1
for p
in self
.parents
]
365 return ('CommitData<tree: %s, parents: %s, author: %s,'
366 ' committer: %s, message: "%s">'
367 ) %
(tree
, parents
, self
.author
, self
.committer
, self
.message
)
368 def commit(self
, repository
):
369 """Commit the commit.
370 @return: The committed commit
372 c
= ['git', 'commit-tree', self
.tree
.sha1
]
373 for p
in self
.parents
:
377 for p
, v1
in ((self
.author
, 'AUTHOR'),
378 (self
.committer
, 'COMMITTER')):
380 for attr
, v2
in (('name', 'NAME'), ('email', 'EMAIL'),
382 if getattr(p
, attr
) != None:
383 env
['GIT_%s_%s' %
(v1
, v2
)] = str(getattr(p
, attr
))
384 sha1
= repository
.run(c
, env
= env
).raw_input(self
.message
386 return repository
.get_commit(sha1
)
388 def parse(cls
, repository
, s
):
389 """Parse a raw git commit description.
390 @return: A new L{CommitData} object
391 @rtype: L{CommitData}"""
392 cd
= cls(parents
= [])
393 lines
= list(s
.splitlines(True))
394 for i
in xrange(len(lines
)):
395 line
= lines
[i
].strip()
397 return cd
.set_message(''.join(lines
[i
+1:]))
398 key
, value
= line
.split(None, 1)
400 cd
= cd
.set_tree(repository
.get_tree(value
))
401 elif key
== 'parent':
402 cd
= cd
.add_parent(repository
.get_commit(value
))
403 elif key
== 'author':
404 cd
= cd
.set_author(Person
.parse(value
))
405 elif key
== 'committer':
406 cd
= cd
.set_committer(Person
.parse(value
))
409 class Commit(GitObject
):
410 """Represents a git commit object. All the actual data contents of the
411 commit object is stored in the L{data} member, which is a
412 L{CommitData} object."""
414 def __init__(self
, repository
, sha1
):
416 self
.__repository
= repository
418 sha1
= property(lambda self
: self
.__sha1
)
421 if self
.__data
== None:
422 self
.__data
= CommitData
.parse(
424 self
.__repository
.cat_object(self
.sha1
))
427 return 'Commit<sha1: %s, data: %s>' %
(self
.sha1
, self
.__data
)
430 """Accessor for the refs stored in a git repository. Will
431 transparently cache the values of all refs."""
432 def __init__(self
, repository
):
433 self
.__repository
= repository
435 def __cache_refs(self
):
436 """(Re-)Build the cache of all refs in the repository."""
438 for line
in self
.__repository
.run(['git', 'show-ref']).output_lines():
439 m
= re
.match(r
'^([0-9a-f]{40})\s+(\S+)$', line
)
440 sha1
, ref
= m
.groups()
441 self
.__refs
[ref
] = sha1
443 """Get the Commit the given ref points to. Throws KeyError if ref
445 if self
.__refs
== None:
447 return self
.__repository
.get_commit(self
.__refs
[ref
])
448 def exists(self
, ref
):
449 """Check if the given ref exists."""
456 def set(self
, ref
, commit
, msg
):
457 """Write the sha1 of the given Commit to the ref. The ref may or may
458 not already exist."""
459 if self
.__refs
== None:
461 old_sha1
= self
.__refs
.get(ref
, '0'*40)
462 new_sha1
= commit
.sha1
463 if old_sha1
!= new_sha1
:
464 self
.__repository
.run(['git', 'update-ref', '-m', msg
,
465 ref
, new_sha1
, old_sha1
]).no_output()
466 self
.__refs
[ref
] = new_sha1
467 def delete(self
, ref
):
468 """Delete the given ref. Throws KeyError if ref doesn't exist."""
469 if self
.__refs
== None:
471 self
.__repository
.run(['git', 'update-ref',
472 '-d', ref
, self
.__refs
[ref
]]).no_output()
475 class ObjectCache(object):
476 """Cache for Python objects, for making sure that we create only one
477 Python object per git object. This reduces memory consumption and
478 makes object comparison very cheap."""
479 def __init__(self
, create
):
481 self
.__create
= create
482 def __getitem__(self
, name
):
483 if not name
in self
.__objects
:
484 self
.__objects
[name
] = self
.__create(name
)
485 return self
.__objects
[name
]
486 def __contains__(self
, name
):
487 return name
in self
.__objects
488 def __setitem__(self
, name
, val
):
489 assert not name
in self
.__objects
490 self
.__objects
[name
] = val
492 class RunWithEnv(object):
493 def run(self
, args
, env
= {}):
494 """Run the given command with an environment given by self.env.
496 @type args: list of strings
497 @param args: Command and argument vector
499 @param env: Extra environment"""
500 return run
.Run(*args
).env(utils
.add_dict(self
.env
, env
))
502 class RunWithEnvCwd(RunWithEnv
):
503 def run(self
, args
, env
= {}):
504 """Run the given command with an environment given by self.env, and
505 current working directory given by self.cwd.
507 @type args: list of strings
508 @param args: Command and argument vector
510 @param env: Extra environment"""
511 return RunWithEnv
.run(self
, args
, env
).cwd(self
.cwd
)
512 def run_in_cwd(self
, args
):
513 """Run the given command with an environment given by self.env and
514 self.env_in_cwd, without changing the current working
517 @type args: list of strings
518 @param args: Command and argument vector"""
519 return RunWithEnv
.run(self
, args
, self
.env_in_cwd
)
521 class CatFileProcess(object):
522 def __init__(self
, repo
):
525 atexit
.register(self
.__shutdown
)
526 def __get_process(self
):
528 self
.__proc
= self
.__repo
.run(['git', 'cat-file', '--batch']
531 def __shutdown(self
):
534 os
.kill(p
.pid(), signal
.SIGTERM
)
536 def cat_file(self
, sha1
):
537 p
= self
.__get_process()
538 p
.stdin
.write('%s\n' % sha
1)
541 # Read until we have the entire status line.
544 s
+= os
.read(p
.stdout
.fileno(), 4096)
545 h
, b
= s
.split('\n', 1)
546 if h
== '%s missing' % sha
1:
547 raise SomeException()
548 hash, type, length
= h
.split()
552 # Read until we have the entire object plus the trailing
554 while len(b
) < length
+ 1:
555 b
+= os
.read(p
.stdout
.fileno(), 4096)
558 class Repository(RunWithEnv
):
559 """Represents a git repository."""
560 def __init__(self
, directory
):
561 self
.__git_dir
= directory
562 self
.__refs
= Refs(self
)
563 self
.__blobs
= ObjectCache(lambda sha1
: Blob(self
, sha1
))
564 self
.__trees
= ObjectCache(lambda sha1
: Tree(self
, sha1
))
565 self
.__commits
= ObjectCache(lambda sha1
: Commit(self
, sha1
))
566 self
.__default_index
= None
567 self
.__default_worktree
= None
568 self
.__default_iw
= None
569 self
.__catfile
= CatFileProcess(self
)
570 env
= property(lambda self
: { 'GIT_DIR': self
.__git_dir
})
573 """Return the default repository."""
575 return cls(run
.Run('git', 'rev-parse', '--git-dir'
577 except run
.RunException
:
578 raise RepositoryException('Cannot find git repository')
580 def current_branch_name(self
):
581 """Return the name of the current branch."""
582 return utils
.strip_prefix('refs/heads/', self
.head_ref
)
584 def default_index(self
):
585 """An L{Index} object representing the default index file for the
587 if self
.__default_index
== None:
588 self
.__default_index
= Index(
589 self
, (os
.environ
.get('GIT_INDEX_FILE', None)
590 or os
.path
.join(self
.__git_dir
, 'index')))
591 return self
.__default_index
592 def temp_index(self
):
593 """Return an L{Index} object representing a new temporary index file
594 for the repository."""
595 return Index(self
, self
.__git_dir
)
597 def default_worktree(self
):
598 """A L{Worktree} object representing the default work tree."""
599 if self
.__default_worktree
== None:
600 path
= os
.environ
.get('GIT_WORK_TREE', None)
602 o
= run
.Run('git', 'rev-parse', '--show-cdup').output_lines()
606 self
.__default_worktree
= Worktree(path
)
607 return self
.__default_worktree
609 def default_iw(self
):
610 """An L{IndexAndWorktree} object representing the default index and
611 work tree for this repository."""
612 if self
.__default_iw
== None:
613 self
.__default_iw
= IndexAndWorktree(self
.default_index
,
614 self
.default_worktree
)
615 return self
.__default_iw
616 directory
= property(lambda self
: self
.__git_dir
)
617 refs
= property(lambda self
: self
.__refs
)
618 def cat_object(self
, sha1
):
619 return self
.__catfile
.cat_file(sha1
)[1]
620 def rev_parse(self
, rev
, discard_stderr
= False, object_type
= 'commit'):
621 assert object_type
in ('commit', 'tree', 'blob')
622 getter
= getattr(self
, 'get_' + object_type
)
624 return getter(self
.run(
625 ['git', 'rev-parse', '%s^{%s}' %
(rev
, object_type
)]
626 ).discard_stderr(discard_stderr
).output_one_line())
627 except run
.RunException
:
628 raise RepositoryException('%s: No such %s' %
(rev
, object_type
))
629 def get_blob(self
, sha1
):
630 return self
.__blobs
[sha1
]
631 def get_tree(self
, sha1
):
632 return self
.__trees
[sha1
]
633 def get_commit(self
, sha1
):
634 return self
.__commits
[sha1
]
635 def get_object(self
, type, sha1
):
636 return { Blob
.typename
: self
.get_blob
,
637 Tree
.typename
: self
.get_tree
,
638 Commit
.typename
: self
.get_commit
}[type](sha1
)
639 def commit(self
, objectdata
):
640 return objectdata
.commit(self
)
644 return self
.run(['git', 'symbolic-ref', '-q', 'HEAD']
646 except run
.RunException
:
647 raise DetachedHeadException()
648 def set_head_ref(self
, ref
, msg
):
649 self
.run(['git', 'symbolic-ref', '-m', msg
, 'HEAD', ref
]).no_output()
650 def get_merge_bases(self
, commit1
, commit2
):
651 """Return a set of merge bases of two commits."""
652 sha1_list
= self
.run(['git', 'merge-base', '--all',
653 commit1
.sha1
, commit2
.sha1
]).output_lines()
654 return [self
.get_commit(sha1
) for sha1
in sha1_list
]
655 def describe(self
, commit
):
656 """Use git describe --all on the given commit."""
657 return self
.run(['git', 'describe', '--all', commit
.sha1
]
658 ).discard_stderr().discard_exitcode().raw_output()
659 def simple_merge(self
, base
, ours
, theirs
):
660 index
= self
.temp_index()
662 result
, index_tree
= index
.merge(base
, ours
, theirs
)
666 def apply(self
, tree
, patch_text
, quiet
):
667 """Given a L{Tree} and a patch, will either return the new L{Tree}
668 that results when the patch is applied, or None if the patch
669 couldn't be applied."""
670 assert isinstance(tree
, Tree
)
673 index
= self
.temp_index()
675 index
.read_tree(tree
)
677 index
.apply(patch_text
, quiet
)
678 return index
.write_tree()
679 except MergeException
:
683 def diff_tree(self
, t1
, t2
, diff_opts
, binary
= True):
684 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
687 @type diff_opts: list of strings
688 @param diff_opts: Extra diff options
690 @return: Patch text"""
691 assert isinstance(t1
, Tree
)
692 assert isinstance(t2
, Tree
)
693 diff_opts
= list(diff_opts
)
694 if binary
and not '--binary' in diff_opts
:
695 diff_opts
.append('--binary')
696 return self
.run(['git', 'diff-tree', '-p'] + diff_opts
697 + [t1
.sha1
, t2
.sha1
]).raw_output()
698 def diff_tree_files(self
, t1
, t2
):
699 """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
700 which they differ. For each file, yield a tuple with the old
701 file mode, the new file mode, the old blob, the new blob, the
702 status, the old filename, and the new filename. Except in case
703 of a copy or a rename, the old and new filenames are
705 assert isinstance(t1
, Tree
)
706 assert isinstance(t2
, Tree
)
707 i
= iter(self
.run(['git', 'diff-tree', '-r', '-z'] + [t1
.sha1
, t2
.sha1
]
708 ).raw_output().split('\0'))
714 omode
, nmode
, osha1
, nsha1
, status
= x
[1:].split(' ')
716 if status
[0] in ['C', 'R']:
720 yield (omode
, nmode
, self
.get_blob(osha1
),
721 self
.get_blob(nsha1
), status
, fn1
, fn2
)
722 except StopIteration:
725 class MergeException(exception
.StgException
):
726 """Exception raised when a merge fails for some reason."""
728 class MergeConflictException(MergeException
):
729 """Exception raised when a merge fails due to conflicts."""
730 def __init__(self
, conflicts
):
731 MergeException
.__init__(self
)
732 self
.conflicts
= conflicts
734 class Index(RunWithEnv
):
735 """Represents a git index file."""
736 def __init__(self
, repository
, filename
):
737 self
.__repository
= repository
738 if os
.path
.isdir(filename
):
739 # Create a temp index in the given directory.
740 self
.__filename
= os
.path
.join(
741 filename
, 'index.temp-%d-%x' %
(os
.getpid(), id(self
)))
744 self
.__filename
= filename
745 env
= property(lambda self
: utils
.add_dict(
746 self
.__repository
.env
, { 'GIT_INDEX_FILE': self
.__filename
}))
747 def read_tree(self
, tree
):
748 self
.run(['git', 'read-tree', tree
.sha1
]).no_output()
749 def write_tree(self
):
750 """Write the index contents to the repository.
751 @return: The resulting L{Tree}
754 return self
.__repository
.get_tree(
755 self
.run(['git', 'write-tree']).discard_stderr(
757 except run
.RunException
:
758 raise MergeException('Conflicting merge')
759 def is_clean(self
, tree
):
760 """Check whether the index is clean relative to the given treeish."""
762 self
.run(['git', 'diff-index', '--quiet', '--cached', tree
.sha1
]
764 except run
.RunException
:
768 def apply(self
, patch_text
, quiet
):
769 """In-index patch application, no worktree involved."""
771 r
= self
.run(['git', 'apply', '--cached']).raw_input(patch_text
)
773 r
= r
.discard_stderr()
775 except run
.RunException
:
776 raise MergeException('Patch does not apply cleanly')
777 def apply_treediff(self
, tree1
, tree2
, quiet
):
778 """Apply the diff from C{tree1} to C{tree2} to the index."""
779 # Passing --full-index here is necessary to support binary
780 # files. It is also sufficient, since the repository already
781 # contains all involved objects; in other words, we don't have
783 self
.apply(self
.__repository
.diff_tree(tree1
, tree2
, ['--full-index']),
785 def merge(self
, base
, ours
, theirs
, current
= None):
786 """Use the index (and only the index) to do a 3-way merge of the
787 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
788 succeed (in which case the first half of the return value is
789 the resulting tree) or fail cleanly (in which case the first
790 half of the return value is C{None}).
792 If C{current} is given (and not C{None}), it is assumed to be
793 the L{Tree} currently stored in the index; this information is
794 used to avoid having to read the right tree (either of C{ours}
795 and C{theirs}) into the index if it's already there. The
796 second half of the return value is the tree now stored in the
797 index, or C{None} if unknown. If the merge succeeded, this is
798 often the merge result."""
799 assert isinstance(base
, Tree
)
800 assert isinstance(ours
, Tree
)
801 assert isinstance(theirs
, Tree
)
802 assert current
== None or isinstance(current
, Tree
)
804 # Take care of the really trivial cases.
806 return (theirs
, current
)
808 return (ours
, current
)
810 return (ours
, current
)
812 if current
== theirs
:
813 # Swap the trees. It doesn't matter since merging is
814 # symmetric, and will allow us to avoid the read_tree()
816 ours
, theirs
= theirs
, ours
820 self
.apply_treediff(base
, theirs
, quiet
= True)
821 result
= self
.write_tree()
822 return (result
, result
)
823 except MergeException
:
826 if os
.path
.isfile(self
.__filename
):
827 os
.remove(self
.__filename
)
829 """The set of conflicting paths."""
831 for line
in self
.run(['git', 'ls-files', '-z', '--unmerged']
832 ).raw_output().split('\0')[:-1]:
833 stat
, path
= line
.split('\t', 1)
837 class Worktree(object):
838 """Represents a git worktree (that is, a checked-out file tree)."""
839 def __init__(self
, directory
):
840 self
.__directory
= directory
841 env
= property(lambda self
: { 'GIT_WORK_TREE': '.' })
842 env_in_cwd
= property(lambda self
: { 'GIT_WORK_TREE': self
.directory
})
843 directory
= property(lambda self
: self
.__directory
)
845 class CheckoutException(exception
.StgException
):
846 """Exception raised when a checkout fails."""
848 class IndexAndWorktree(RunWithEnvCwd
):
849 """Represents a git index and a worktree. Anything that an index or
850 worktree can do on their own are handled by the L{Index} and
851 L{Worktree} classes; this class concerns itself with the
852 operations that require both."""
853 def __init__(self
, index
, worktree
):
855 self
.__worktree
= worktree
856 index
= property(lambda self
: self
.__index
)
857 env
= property(lambda self
: utils
.add_dict(self
.__index
.env
,
858 self
.__worktree
.env
))
859 env_in_cwd
= property(lambda self
: self
.__worktree
.env_in_cwd
)
860 cwd
= property(lambda self
: self
.__worktree
.directory
)
861 def checkout_hard(self
, tree
):
862 assert isinstance(tree
, Tree
)
863 self
.run(['git', 'read-tree', '--reset', '-u', tree
.sha1
]
865 def checkout(self
, old_tree
, new_tree
):
866 # TODO: Optionally do a 3-way instead of doing nothing when we
867 # have a problem. Or maybe we should stash changes in a patch?
868 assert isinstance(old_tree
, Tree
)
869 assert isinstance(new_tree
, Tree
)
871 self
.run(['git', 'read-tree', '-u', '-m',
872 '--exclude-per-directory=.gitignore',
873 old_tree
.sha1
, new_tree
.sha1
]
875 except run
.RunException
:
876 raise CheckoutException('Index/workdir dirty')
877 def merge(self
, base
, ours
, theirs
, interactive
= False):
878 assert isinstance(base
, Tree
)
879 assert isinstance(ours
, Tree
)
880 assert isinstance(theirs
, Tree
)
882 r
= self
.run(['git', 'merge-recursive', base
.sha1
, '--', ours
.sha1
,
884 env
= { 'GITHEAD_%s' % base
.sha1
: 'ancestor',
885 'GITHEAD_%s' % ours
.sha1
: 'current',
886 'GITHEAD_%s' % theirs
.sha1
: 'patched'})
888 output
= r
.output_lines()
890 # There were conflicts
894 conflicts
= [l
for l
in output
if l
.startswith('CONFLICT')]
895 raise MergeConflictException(conflicts
)
896 except run
.RunException
, e
:
897 raise MergeException('Index/worktree dirty')
898 def mergetool(self
, files
= ()):
899 """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
900 any outstanding conflicts. If 'not files', all the files in an
901 unmerged state will be processed."""
902 self
.run(['git', 'mergetool'] + list(files
)).returns([0, 1]).run()
903 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
905 conflicts
= ['CONFLICT ' + f
for f
in self
.index
.conflicts()]
907 raise MergeConflictException(conflicts
)
908 def changed_files(self
, tree
, pathlimits
= []):
909 """Return the set of files in the worktree that have changed with
910 respect to C{tree}. The listing is optionally restricted to
911 those files that match any of the path limiters given.
913 The path limiters are relative to the current working
914 directory; the returned file names are relative to the
916 assert isinstance(tree
, Tree
)
917 return set(self
.run_in_cwd(
918 ['git', 'diff-index', tree
.sha1
, '--name-only', '-z', '--']
919 + list(pathlimits
)).raw_output().split('\0')[:-1])
920 def update_index(self
, paths
):
921 """Update the index with files from the worktree. C{paths} is an
922 iterable of paths relative to the root of the repository."""
923 cmd
= ['git', 'update-index', '--remove']
924 self
.run(cmd
+ ['-z', '--stdin']
925 ).input_nulterm(paths
).discard_output()
926 def worktree_clean(self
):
927 """Check whether the worktree is clean relative to index."""
929 self
.run(['git', 'update-index', '--refresh']).discard_output()
930 except run
.RunException
:
935 class Branch(object):
936 """Represents a Git branch."""
937 def __init__(self
, repository
, name
):
938 self
.__repository
= repository
943 raise BranchException('%s: no such branch' % name
)
945 name
= property(lambda self
: self
.__name
)
946 repository
= property(lambda self
: self
.__repository
)
949 return 'refs/heads/%s' % self
.__name
952 return self
.__repository
.refs
.get(self
.__ref())
953 def set_head(self
, commit
, msg
):
954 self
.__repository
.refs
.set(self
.__ref(), commit
, msg
)
956 def set_parent_remote(self
, name
):
957 value
= config
.set('branch.%s.remote' % self
.__name
, name
)
958 def set_parent_branch(self
, name
):
959 if config
.get('branch.%s.remote' % self
.__name
):
960 # Never set merge if remote is not set to avoid
961 # possibly-erroneous lookups into 'origin'
962 config
.set('branch.%s.merge' % self
.__name
, name
)
965 def create(cls
, repository
, name
, create_at
= None):
966 """Create a new Git branch and return the corresponding
969 branch
= cls(repository
, name
)
970 except BranchException
:
973 raise BranchException('%s: branch already exists' % name
)
975 cmd
= ['git', 'branch']
977 cmd
.append(create_at
.sha1
)
978 repository
.run(['git', 'branch', create_at
.sha1
]).discard_output()
980 return cls(repository
, name
)
983 """Return the diffstat of the supplied diff."""
984 return run
.Run('git', 'apply', '--stat', '--summary'
985 ).raw_input(diff
).raw_output()
987 def clone(remote
, local
):
988 """Clone a remote repository using 'git clone'."""
989 run
.Run('git', 'clone', remote
, local
).run()