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 def system_date(datestring
):
91 m
= re
.match(r
"^(.+)([+-]\d\d:?\d\d)$", datestring
)
93 # Time zone included; we parse it ourselves, since "date"
94 # would convert it to the local time zone.
97 t
= run
.Run("date", "+%Y-%m-%d-%H-%M-%S", "-d", ds
99 except run
.RunException
:
102 # Time zone not included; we ask "date" to provide it for us.
104 d
= run
.Run("date", "+%Y-%m-%d-%H-%M-%S_%z", "-d", datestring
106 except run
.RunException
:
108 (t
, z
) = d
.split("_")
110 return datetime(*[int(x
) for x
in t
.split("-")], tzinfo
=TimeZone(z
))
112 raise DateException(datestring
, "date")
114 class Date(Immutable
, Repr
):
115 """Represents a timestamp used in git commits."""
116 def __init__(self
, datestring
):
117 # Try git-formatted date.
118 m
= re
.match(r
'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring
)
121 self
.__time
= datetime
.fromtimestamp(int(m
.group(1)),
122 TimeZone(m
.group(2)))
124 raise DateException(datestring
, 'date')
127 # Try iso-formatted date.
128 m
= re
.match(r
'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+'
129 + r
'([+-]\d\d:?\d\d)$', datestring
)
132 self
.__time
= datetime(
133 *[int(m
.group(i
+ 1)) for i
in xrange(6)],
134 **{'tzinfo': TimeZone(m
.group(7))})
136 raise DateException(datestring
, 'date')
139 # Try parsing with the system's "date" command.
140 sd
= system_date(datestring
)
145 raise DateException(datestring
, 'date')
147 return self
.isoformat()
149 """Human-friendly ISO 8601 format."""
150 return '%s %s' % (self
.__time
.replace(tzinfo
= None).isoformat(' '),
153 def maybe(cls
, datestring
):
154 """Return a new object initialized with the argument if it contains a
155 value (otherwise, just return the argument)."""
156 if datestring
in [None, NoValue
]:
158 return cls(datestring
)
160 class Person(Immutable
, Repr
):
161 """Represents an author or committer in a git commit object. Contains
162 name, email and timestamp."""
163 def __init__(self
, name
= NoValue
, email
= NoValue
,
164 date
= NoValue
, defaults
= NoValue
):
165 d
= make_defaults(defaults
)
166 self
.__name
= d(name
, 'name')
167 self
.__email
= d(email
, 'email')
168 self
.__date
= d(date
, 'date')
169 assert isinstance(self
.__date
, Date
) or self
.__date
in [None, NoValue
]
170 name
= property(lambda self
: self
.__name
)
171 email
= property(lambda self
: self
.__email
)
172 name_email
= property(lambda self
: '%s <%s>' % (self
.name
, self
.email
))
173 date
= property(lambda self
: self
.__date
)
174 def set_name(self
, name
):
175 return type(self
)(name
= name
, defaults
= self
)
176 def set_email(self
, email
):
177 return type(self
)(email
= email
, defaults
= self
)
178 def set_date(self
, date
):
179 return type(self
)(date
= date
, defaults
= self
)
181 return '%s %s' % (self
.name_email
, self
.date
)
184 m
= re
.match(r
'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s
)
186 name
= m
.group(1).strip()
188 date
= Date(m
.group(3))
189 return cls(name
, email
, date
)
192 if not hasattr(cls
, '__user'):
193 cls
.__user
= cls(name
= config
.get('user.name'),
194 email
= config
.get('user.email'))
198 if not hasattr(cls
, '__author'):
200 name
= os
.environ
.get('GIT_AUTHOR_NAME', NoValue
),
201 email
= os
.environ
.get('GIT_AUTHOR_EMAIL', NoValue
),
202 date
= Date
.maybe(os
.environ
.get('GIT_AUTHOR_DATE', NoValue
)),
203 defaults
= cls
.user())
207 if not hasattr(cls
, '__committer'):
208 cls
.__committer
= cls(
209 name
= os
.environ
.get('GIT_COMMITTER_NAME', NoValue
),
210 email
= os
.environ
.get('GIT_COMMITTER_EMAIL', NoValue
),
212 os
.environ
.get('GIT_COMMITTER_DATE', NoValue
)),
213 defaults
= cls
.user())
214 return cls
.__committer
216 class GitObject(Immutable
, Repr
):
217 """Base class for all git objects. One git object is represented by at
218 most one C{GitObject}, which makes it possible to compare them
219 using normal Python object comparison; it also ensures we don't
220 waste more memory than necessary."""
222 class BlobData(Immutable
, Repr
):
223 """Represents the data contents of a git blob object."""
224 def __init__(self
, string
):
225 self
.__string
= str(string
)
226 str = property(lambda self
: self
.__string
)
227 def commit(self
, repository
):
229 @return: The committed blob
231 sha1
= repository
.run(['git', 'hash-object', '-w', '--stdin']
232 ).raw_input(self
.str).output_one_line()
233 return repository
.get_blob(sha1
)
235 class Blob(GitObject
):
236 """Represents a git blob object. All the actual data contents of the
237 blob object is stored in the L{data} member, which is a
238 L{BlobData} object."""
240 default_perm
= '100644'
241 def __init__(self
, repository
, sha1
):
242 self
.__repository
= repository
244 sha1
= property(lambda self
: self
.__sha
1)
246 return 'Blob<%s>' % self
.sha1
249 return BlobData(self
.__repository
.cat_object(self
.sha1
))
251 class ImmutableDict(dict):
252 """A dictionary that cannot be modified once it's been created."""
253 def error(*args
, **kwargs
):
254 raise TypeError('Cannot modify immutable dict')
263 class TreeData(Immutable
, Repr
):
264 """Represents the data contents of a git tree object."""
267 if isinstance(po
, GitObject
):
268 perm
, object = po
.default_perm
, po
272 def __init__(self
, entries
):
273 """Create a new L{TreeData} object from the given mapping from names
274 (strings) to either (I{permission}, I{object}) tuples or just
276 self
.__entries
= ImmutableDict((name
, self
.__x
(po
))
277 for (name
, po
) in entries
.iteritems())
278 entries
= property(lambda self
: self
.__entries
)
279 """Map from name to (I{permission}, I{object}) tuple."""
280 def set_entry(self
, name
, po
):
281 """Create a new L{TreeData} object identical to this one, except that
282 it maps C{name} to C{po}.
284 @param name: Name of the changed mapping
286 @param po: Value of the changed mapping
287 @type po: L{Blob} or L{Tree} or (C{str}, L{Blob} or L{Tree})
288 @return: The new L{TreeData} object
289 @rtype: L{TreeData}"""
290 e
= dict(self
.entries
)
291 e
[name
] = self
.__x
(po
)
293 def del_entry(self
, name
):
294 """Create a new L{TreeData} object identical to this one, except that
295 it doesn't map C{name} to anything.
297 @param name: Name of the deleted mapping
299 @return: The new L{TreeData} object
300 @rtype: L{TreeData}"""
301 e
= dict(self
.entries
)
304 def commit(self
, repository
):
306 @return: The committed tree
309 '%s %s %s\t%s\0' % (mode
, obj
.typename
, obj
.sha1
, name
)
310 for (name
, (mode
, obj
)) in self
.entries
.iteritems())
311 sha1
= repository
.run(['git', 'mktree', '-z']
312 ).raw_input(listing
).output_one_line()
313 return repository
.get_tree(sha1
)
315 def parse(cls
, repository
, s
):
316 """Parse a raw git tree description.
318 @return: A new L{TreeData} object
319 @rtype: L{TreeData}"""
321 for line
in s
.split('\0')[:-1]:
322 m
= re
.match(r
'^([0-7]{6}) ([a-z]+) ([0-9a-f]{40})\t(.*)$', line
)
324 perm
, type, sha1
, name
= m
.groups()
325 entries
[name
] = (perm
, repository
.get_object(type, sha1
))
328 class Tree(GitObject
):
329 """Represents a git tree object. All the actual data contents of the
330 tree object is stored in the L{data} member, which is a
331 L{TreeData} object."""
333 default_perm
= '040000'
334 def __init__(self
, repository
, sha1
):
336 self
.__repository
= repository
338 sha1
= property(lambda self
: self
.__sha
1)
341 if self
.__data
== None:
342 self
.__data
= TreeData
.parse(
344 self
.__repository
.run(['git', 'ls-tree', '-z', self
.sha1
]
348 return 'Tree<sha1: %s>' % self
.sha1
350 class CommitData(Immutable
, Repr
):
351 """Represents the data contents of a git commit object."""
352 def __init__(self
, tree
= NoValue
, parents
= NoValue
, author
= NoValue
,
353 committer
= NoValue
, message
= NoValue
, defaults
= NoValue
):
354 d
= make_defaults(defaults
)
355 self
.__tree
= d(tree
, 'tree')
356 self
.__parents
= d(parents
, 'parents')
357 self
.__author
= d(author
, 'author', Person
.author
)
358 self
.__committer
= d(committer
, 'committer', Person
.committer
)
359 self
.__message
= d(message
, 'message')
363 for p
, v1
in ((self
.author
, 'AUTHOR'),
364 (self
.committer
, 'COMMITTER')):
366 for attr
, v2
in (('name', 'NAME'), ('email', 'EMAIL'),
368 if getattr(p
, attr
) != None:
369 env
['GIT_%s_%s' % (v1
, v2
)] = str(getattr(p
, attr
))
371 tree
= property(lambda self
: self
.__tree
)
372 parents
= property(lambda self
: self
.__parents
)
375 assert len(self
.__parents
) == 1
376 return self
.__parents
[0]
377 author
= property(lambda self
: self
.__author
)
378 committer
= property(lambda self
: self
.__committer
)
379 message
= property(lambda self
: self
.__message
)
380 def set_tree(self
, tree
):
381 return type(self
)(tree
= tree
, defaults
= self
)
382 def set_parents(self
, parents
):
383 return type(self
)(parents
= parents
, defaults
= self
)
384 def add_parent(self
, parent
):
385 return type(self
)(parents
= list(self
.parents
or []) + [parent
],
387 def set_parent(self
, parent
):
388 return self
.set_parents([parent
])
389 def set_author(self
, author
):
390 return type(self
)(author
= author
, defaults
= self
)
391 def set_committer(self
, committer
):
392 return type(self
)(committer
= committer
, defaults
= self
)
393 def set_message(self
, message
):
394 return type(self
)(message
= message
, defaults
= self
)
395 def is_nochange(self
):
396 return len(self
.parents
) == 1 and self
.tree
== self
.parent
.data
.tree
398 if self
.tree
== None:
401 tree
= self
.tree
.sha1
402 if self
.parents
== None:
405 parents
= [p
.sha1
for p
in self
.parents
]
406 return ('CommitData<tree: %s, parents: %s, author: %s,'
407 ' committer: %s, message: "%s">'
408 ) % (tree
, parents
, self
.author
, self
.committer
, self
.message
)
409 def commit(self
, repository
):
410 """Commit the commit.
411 @return: The committed commit
413 c
= ['git', 'commit-tree', self
.tree
.sha1
]
414 for p
in self
.parents
:
417 sha1
= repository
.run(c
, env
= self
.env
).raw_input(self
.message
419 return repository
.get_commit(sha1
)
421 def parse(cls
, repository
, s
):
422 """Parse a raw git commit description.
423 @return: A new L{CommitData} object
424 @rtype: L{CommitData}"""
425 cd
= cls(parents
= [])
427 raw_lines
= s
.split('\n')
428 # Collapse multi-line header lines
429 for i
, line
in enumerate(raw_lines
):
431 cd
= cd
.set_message('\n'.join(raw_lines
[i
+1:]))
433 if line
.startswith(' '):
435 lines
[-1] += '\n' + line
[1:]
440 key
, value
= line
.split(' ', 1)
442 cd
= cd
.set_tree(repository
.get_tree(value
))
443 elif key
== 'parent':
444 cd
= cd
.add_parent(repository
.get_commit(value
))
445 elif key
== 'author':
446 cd
= cd
.set_author(Person
.parse(value
))
447 elif key
== 'committer':
448 cd
= cd
.set_committer(Person
.parse(value
))
452 class Commit(GitObject
):
453 """Represents a git commit object. All the actual data contents of the
454 commit object is stored in the L{data} member, which is a
455 L{CommitData} object."""
457 def __init__(self
, repository
, sha1
):
459 self
.__repository
= repository
461 sha1
= property(lambda self
: self
.__sha
1)
464 if self
.__data
== None:
465 self
.__data
= CommitData
.parse(
467 self
.__repository
.cat_object(self
.sha1
))
470 return 'Commit<sha1: %s, data: %s>' % (self
.sha1
, self
.__data
)
473 """Accessor for the refs stored in a git repository. Will
474 transparently cache the values of all refs."""
475 def __init__(self
, repository
):
476 self
.__repository
= repository
478 def __cache_refs(self
):
479 """(Re-)Build the cache of all refs in the repository."""
481 runner
= self
.__repository
.run(['git', 'show-ref'])
483 lines
= runner
.output_lines()
484 except run
.RunException
:
485 # as this happens both in non-git trees and empty git
486 # trees, we silently ignore this error
489 m
= re
.match(r
'^([0-9a-f]{40})\s+(\S+)$', line
)
490 sha1
, ref
= m
.groups()
491 self
.__refs
[ref
] = sha1
493 """Get the Commit the given ref points to. Throws KeyError if ref
495 if self
.__refs
== None:
497 return self
.__repository
.get_commit(self
.__refs
[ref
])
498 def exists(self
, ref
):
499 """Check if the given ref exists."""
506 def set(self
, ref
, commit
, msg
):
507 """Write the sha1 of the given Commit to the ref. The ref may or may
508 not already exist."""
509 if self
.__refs
== None:
511 old_sha1
= self
.__refs
.get(ref
, '0'*40)
512 new_sha1
= commit
.sha1
513 if old_sha1
!= new_sha1
:
514 self
.__repository
.run(['git', 'update-ref', '-m', msg
,
515 ref
, new_sha1
, old_sha1
]).no_output()
516 self
.__refs
[ref
] = new_sha1
517 def delete(self
, ref
):
518 """Delete the given ref. Throws KeyError if ref doesn't exist."""
519 if self
.__refs
== None:
521 self
.__repository
.run(['git', 'update-ref',
522 '-d', ref
, self
.__refs
[ref
]]).no_output()
525 class ObjectCache(object):
526 """Cache for Python objects, for making sure that we create only one
527 Python object per git object. This reduces memory consumption and
528 makes object comparison very cheap."""
529 def __init__(self
, create
):
531 self
.__create
= create
532 def __getitem__(self
, name
):
533 if not name
in self
.__objects
:
534 self
.__objects
[name
] = self
.__create
(name
)
535 return self
.__objects
[name
]
536 def __contains__(self
, name
):
537 return name
in self
.__objects
538 def __setitem__(self
, name
, val
):
539 assert not name
in self
.__objects
540 self
.__objects
[name
] = val
542 class RunWithEnv(object):
543 def run(self
, args
, env
= {}):
544 """Run the given command with an environment given by self.env.
546 @type args: list of strings
547 @param args: Command and argument vector
549 @param env: Extra environment"""
550 return run
.Run(*args
).env(utils
.add_dict(self
.env
, env
))
552 class RunWithEnvCwd(RunWithEnv
):
553 def run(self
, args
, env
= {}):
554 """Run the given command with an environment given by self.env, and
555 current working directory given by self.cwd.
557 @type args: list of strings
558 @param args: Command and argument vector
560 @param env: Extra environment"""
561 return RunWithEnv
.run(self
, args
, env
).cwd(self
.cwd
)
562 def run_in_cwd(self
, args
):
563 """Run the given command with an environment given by self.env and
564 self.env_in_cwd, without changing the current working
567 @type args: list of strings
568 @param args: Command and argument vector"""
569 return RunWithEnv
.run(self
, args
, self
.env_in_cwd
)
571 class CatFileProcess(object):
572 def __init__(self
, repo
):
575 atexit
.register(self
.__shutdown
)
576 def __get_process(self
):
578 self
.__proc
= self
.__repo
.run(['git', 'cat-file', '--batch']
581 def __shutdown(self
):
585 os
.kill(p
.pid(), signal
.SIGTERM
)
587 def cat_file(self
, sha1
):
588 p
= self
.__get
_process
()
589 p
.stdin
.write('%s\n' % sha1
)
592 # Read until we have the entire status line.
595 s
+= os
.read(p
.stdout
.fileno(), 4096)
596 h
, b
= s
.split('\n', 1)
597 if h
== '%s missing' % sha1
:
598 raise SomeException()
599 hash, type, length
= h
.split()
603 # Read until we have the entire object plus the trailing
605 while len(b
) < length
+ 1:
606 b
+= os
.read(p
.stdout
.fileno(), 4096)
609 class DiffTreeProcesses(object):
610 def __init__(self
, repo
):
613 atexit
.register(self
.__shutdown
)
614 def __get_process(self
, args
):
616 if not args
in self
.__procs
:
617 self
.__procs
[args
] = self
.__repo
.run(
618 ['git', 'diff-tree', '--stdin'] + list(args
)).run_background()
619 return self
.__procs
[args
]
620 def __shutdown(self
):
621 for p
in self
.__procs
.values():
622 os
.kill(p
.pid(), signal
.SIGTERM
)
624 def diff_trees(self
, args
, sha1a
, sha1b
):
625 p
= self
.__get
_process
(args
)
626 query
= '%s %s\n' % (sha1a
, sha1b
)
627 end
= 'EOF\n' # arbitrary string that's not a 40-digit hex number
628 p
.stdin
.write(query
+ end
)
631 while not (s
.endswith('\n' + end
) or s
.endswith('\0' + end
)):
632 s
+= os
.read(p
.stdout
.fileno(), 4096)
633 assert s
.startswith(query
)
634 assert s
.endswith(end
)
635 return s
[len(query
):-len(end
)]
637 class Repository(RunWithEnv
):
638 """Represents a git repository."""
639 def __init__(self
, directory
):
640 self
.__git
_dir
= directory
641 self
.__refs
= Refs(self
)
642 self
.__blobs
= ObjectCache(lambda sha1
: Blob(self
, sha1
))
643 self
.__trees
= ObjectCache(lambda sha1
: Tree(self
, sha1
))
644 self
.__commits
= ObjectCache(lambda sha1
: Commit(self
, sha1
))
645 self
.__default
_index
= None
646 self
.__default
_worktree
= None
647 self
.__default
_iw
= None
648 self
.__catfile
= CatFileProcess(self
)
649 self
.__difftree
= DiffTreeProcesses(self
)
650 env
= property(lambda self
: { 'GIT_DIR': self
.__git
_dir
})
653 """Return the default repository."""
655 return cls(run
.Run('git', 'rev-parse', '--git-dir'
657 except run
.RunException
:
658 raise RepositoryException('Cannot find git repository')
660 def current_branch_name(self
):
661 """Return the name of the current branch."""
662 return utils
.strip_prefix('refs/heads/', self
.head_ref
)
664 def default_index(self
):
665 """An L{Index} object representing the default index file for the
667 if self
.__default
_index
== None:
668 self
.__default
_index
= Index(
669 self
, (os
.environ
.get('GIT_INDEX_FILE', None)
670 or os
.path
.join(self
.__git
_dir
, 'index')))
671 return self
.__default
_index
672 def temp_index(self
):
673 """Return an L{Index} object representing a new temporary index file
674 for the repository."""
675 return Index(self
, self
.__git
_dir
)
677 def default_worktree(self
):
678 """A L{Worktree} object representing the default work tree."""
679 if self
.__default
_worktree
== None:
680 path
= os
.environ
.get('GIT_WORK_TREE', None)
682 o
= run
.Run('git', 'rev-parse', '--show-cdup').output_lines()
686 self
.__default
_worktree
= Worktree(path
)
687 return self
.__default
_worktree
689 def default_iw(self
):
690 """An L{IndexAndWorktree} object representing the default index and
691 work tree for this repository."""
692 if self
.__default
_iw
== None:
693 self
.__default
_iw
= IndexAndWorktree(self
.default_index
,
694 self
.default_worktree
)
695 return self
.__default
_iw
696 directory
= property(lambda self
: self
.__git
_dir
)
697 refs
= property(lambda self
: self
.__refs
)
698 def cat_object(self
, sha1
):
699 return self
.__catfile
.cat_file(sha1
)[1]
700 def rev_parse(self
, rev
, discard_stderr
= False, object_type
= 'commit'):
701 assert object_type
in ('commit', 'tree', 'blob')
702 getter
= getattr(self
, 'get_' + object_type
)
704 return getter(self
.run(
705 ['git', 'rev-parse', '%s^{%s}' % (rev
, object_type
)]
706 ).discard_stderr(discard_stderr
).output_one_line())
707 except run
.RunException
:
708 raise RepositoryException('%s: No such %s' % (rev
, object_type
))
709 def get_blob(self
, sha1
):
710 return self
.__blobs
[sha1
]
711 def get_tree(self
, sha1
):
712 return self
.__trees
[sha1
]
713 def get_commit(self
, sha1
):
714 return self
.__commits
[sha1
]
715 def get_object(self
, type, sha1
):
716 return { Blob
.typename
: self
.get_blob
,
717 Tree
.typename
: self
.get_tree
,
718 Commit
.typename
: self
.get_commit
}[type](sha1
)
719 def commit(self
, objectdata
):
720 return objectdata
.commit(self
)
724 return self
.run(['git', 'symbolic-ref', '-q', 'HEAD']
726 except run
.RunException
:
727 raise DetachedHeadException()
728 def set_head_ref(self
, ref
, msg
):
729 self
.run(['git', 'symbolic-ref', '-m', msg
, 'HEAD', ref
]).no_output()
730 def get_merge_bases(self
, commit1
, commit2
):
731 """Return a set of merge bases of two commits."""
732 sha1_list
= self
.run(['git', 'merge-base', '--all',
733 commit1
.sha1
, commit2
.sha1
]).output_lines()
734 return [self
.get_commit(sha1
) for sha1
in sha1_list
]
735 def describe(self
, commit
):
736 """Use git describe --all on the given commit."""
737 return self
.run(['git', 'describe', '--all', commit
.sha1
]
738 ).discard_stderr().discard_exitcode().raw_output()
739 def simple_merge(self
, base
, ours
, theirs
):
740 index
= self
.temp_index()
742 result
, index_tree
= index
.merge(base
, ours
, theirs
)
746 def apply(self
, tree
, patch_text
, quiet
):
747 """Given a L{Tree} and a patch, will either return the new L{Tree}
748 that results when the patch is applied, or None if the patch
749 couldn't be applied."""
750 assert isinstance(tree
, Tree
)
753 index
= self
.temp_index()
755 index
.read_tree(tree
)
757 index
.apply(patch_text
, quiet
)
758 return index
.write_tree()
759 except MergeException
:
763 def diff_tree(self
, t1
, t2
, diff_opts
, binary
= True):
764 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
767 @type diff_opts: list of strings
768 @param diff_opts: Extra diff options
770 @return: Patch text"""
771 assert isinstance(t1
, Tree
)
772 assert isinstance(t2
, Tree
)
773 diff_opts
= list(diff_opts
)
774 if binary
and not '--binary' in diff_opts
:
775 diff_opts
.append('--binary')
776 return self
.__difftree
.diff_trees(['-p'] + diff_opts
,
778 def diff_tree_files(self
, t1
, t2
):
779 """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
780 which they differ. For each file, yield a tuple with the old
781 file mode, the new file mode, the old blob, the new blob, the
782 status, the old filename, and the new filename. Except in case
783 of a copy or a rename, the old and new filenames are
785 assert isinstance(t1
, Tree
)
786 assert isinstance(t2
, Tree
)
787 i
= iter(self
.__difftree
.diff_trees(
788 ['-r', '-z'], t1
.sha1
, t2
.sha1
).split('\0'))
794 omode
, nmode
, osha1
, nsha1
, status
= x
[1:].split(' ')
796 if status
[0] in ['C', 'R']:
800 yield (omode
, nmode
, self
.get_blob(osha1
),
801 self
.get_blob(nsha1
), status
, fn1
, fn2
)
802 except StopIteration:
805 class MergeException(exception
.StgException
):
806 """Exception raised when a merge fails for some reason."""
808 class MergeConflictException(MergeException
):
809 """Exception raised when a merge fails due to conflicts."""
810 def __init__(self
, conflicts
):
811 MergeException
.__init
__(self
)
812 self
.conflicts
= conflicts
814 class Index(RunWithEnv
):
815 """Represents a git index file."""
816 def __init__(self
, repository
, filename
):
817 self
.__repository
= repository
818 if os
.path
.isdir(filename
):
819 # Create a temp index in the given directory.
820 self
.__filename
= os
.path
.join(
821 filename
, 'index.temp-%d-%x' % (os
.getpid(), id(self
)))
824 self
.__filename
= filename
825 env
= property(lambda self
: utils
.add_dict(
826 self
.__repository
.env
, { 'GIT_INDEX_FILE': self
.__filename
}))
827 def read_tree(self
, tree
):
828 self
.run(['git', 'read-tree', tree
.sha1
]).no_output()
829 def write_tree(self
):
830 """Write the index contents to the repository.
831 @return: The resulting L{Tree}
834 return self
.__repository
.get_tree(
835 self
.run(['git', 'write-tree']).discard_stderr(
837 except run
.RunException
:
838 raise MergeException('Conflicting merge')
839 def is_clean(self
, tree
):
840 """Check whether the index is clean relative to the given treeish."""
842 self
.run(['git', 'diff-index', '--quiet', '--cached', tree
.sha1
]
844 except run
.RunException
:
848 def apply(self
, patch_text
, quiet
):
849 """In-index patch application, no worktree involved."""
851 r
= self
.run(['git', 'apply', '--cached']).raw_input(patch_text
)
853 r
= r
.discard_stderr()
855 except run
.RunException
:
856 raise MergeException('Patch does not apply cleanly')
857 def apply_treediff(self
, tree1
, tree2
, quiet
):
858 """Apply the diff from C{tree1} to C{tree2} to the index."""
859 # Passing --full-index here is necessary to support binary
860 # files. It is also sufficient, since the repository already
861 # contains all involved objects; in other words, we don't have
863 self
.apply(self
.__repository
.diff_tree(tree1
, tree2
, ['--full-index']),
865 def merge(self
, base
, ours
, theirs
, current
= None):
866 """Use the index (and only the index) to do a 3-way merge of the
867 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
868 succeed (in which case the first half of the return value is
869 the resulting tree) or fail cleanly (in which case the first
870 half of the return value is C{None}).
872 If C{current} is given (and not C{None}), it is assumed to be
873 the L{Tree} currently stored in the index; this information is
874 used to avoid having to read the right tree (either of C{ours}
875 and C{theirs}) into the index if it's already there. The
876 second half of the return value is the tree now stored in the
877 index, or C{None} if unknown. If the merge succeeded, this is
878 often the merge result."""
879 assert isinstance(base
, Tree
)
880 assert isinstance(ours
, Tree
)
881 assert isinstance(theirs
, Tree
)
882 assert current
== None or isinstance(current
, Tree
)
884 # Take care of the really trivial cases.
886 return (theirs
, current
)
888 return (ours
, current
)
890 return (ours
, current
)
892 if current
== theirs
:
893 # Swap the trees. It doesn't matter since merging is
894 # symmetric, and will allow us to avoid the read_tree()
896 ours
, theirs
= theirs
, ours
900 self
.apply_treediff(base
, theirs
, quiet
= True)
901 result
= self
.write_tree()
902 return (result
, result
)
903 except MergeException
:
906 if os
.path
.isfile(self
.__filename
):
907 os
.remove(self
.__filename
)
909 """The set of conflicting paths."""
911 for line
in self
.run(['git', 'ls-files', '-z', '--unmerged']
912 ).raw_output().split('\0')[:-1]:
913 stat
, path
= line
.split('\t', 1)
917 class Worktree(object):
918 """Represents a git worktree (that is, a checked-out file tree)."""
919 def __init__(self
, directory
):
920 self
.__directory
= directory
921 env
= property(lambda self
: { 'GIT_WORK_TREE': '.' })
922 env_in_cwd
= property(lambda self
: { 'GIT_WORK_TREE': self
.directory
})
923 directory
= property(lambda self
: self
.__directory
)
925 class CheckoutException(exception
.StgException
):
926 """Exception raised when a checkout fails."""
928 class IndexAndWorktree(RunWithEnvCwd
):
929 """Represents a git index and a worktree. Anything that an index or
930 worktree can do on their own are handled by the L{Index} and
931 L{Worktree} classes; this class concerns itself with the
932 operations that require both."""
933 def __init__(self
, index
, worktree
):
935 self
.__worktree
= worktree
936 index
= property(lambda self
: self
.__index
)
937 env
= property(lambda self
: utils
.add_dict(self
.__index
.env
,
938 self
.__worktree
.env
))
939 env_in_cwd
= property(lambda self
: self
.__worktree
.env_in_cwd
)
940 cwd
= property(lambda self
: self
.__worktree
.directory
)
941 def checkout_hard(self
, tree
):
942 assert isinstance(tree
, Tree
)
943 self
.run(['git', 'read-tree', '--reset', '-u', tree
.sha1
]
945 def checkout(self
, old_tree
, new_tree
):
946 # TODO: Optionally do a 3-way instead of doing nothing when we
947 # have a problem. Or maybe we should stash changes in a patch?
948 assert isinstance(old_tree
, Tree
)
949 assert isinstance(new_tree
, Tree
)
951 self
.run(['git', 'read-tree', '-u', '-m',
952 '--exclude-per-directory=.gitignore',
953 old_tree
.sha1
, new_tree
.sha1
]
955 except run
.RunException
:
956 raise CheckoutException('Index/workdir dirty')
957 def merge(self
, base
, ours
, theirs
, interactive
= False):
958 assert isinstance(base
, Tree
)
959 assert isinstance(ours
, Tree
)
960 assert isinstance(theirs
, Tree
)
962 r
= self
.run(['git', 'merge-recursive', base
.sha1
, '--', ours
.sha1
,
964 env
= { 'GITHEAD_%s' % base
.sha1
: 'ancestor',
965 'GITHEAD_%s' % ours
.sha1
: 'current',
966 'GITHEAD_%s' % theirs
.sha1
: 'patched'})
968 output
= r
.output_lines()
970 # There were conflicts
974 conflicts
= [l
for l
in output
if l
.startswith('CONFLICT')]
975 raise MergeConflictException(conflicts
)
976 except run
.RunException
, e
:
977 raise MergeException('Index/worktree dirty')
978 def mergetool(self
, files
= ()):
979 """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
980 any outstanding conflicts. If 'not files', all the files in an
981 unmerged state will be processed."""
982 self
.run(['git', 'mergetool'] + list(files
)).returns([0, 1]).run()
983 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
985 conflicts
= ['CONFLICT ' + f
for f
in self
.index
.conflicts()]
987 raise MergeConflictException(conflicts
)
988 def changed_files(self
, tree
, pathlimits
= []):
989 """Return the set of files in the worktree that have changed with
990 respect to C{tree}. The listing is optionally restricted to
991 those files that match any of the path limiters given.
993 The path limiters are relative to the current working
994 directory; the returned file names are relative to the
996 assert isinstance(tree
, Tree
)
997 return set(self
.run_in_cwd(
998 ['git', 'diff-index', tree
.sha1
, '--name-only', '-z', '--']
999 + list(pathlimits
)).raw_output().split('\0')[:-1])
1000 def update_index(self
, paths
):
1001 """Update the index with files from the worktree. C{paths} is an
1002 iterable of paths relative to the root of the repository."""
1003 cmd
= ['git', 'update-index', '--remove']
1004 self
.run(cmd
+ ['-z', '--stdin']
1005 ).input_nulterm(paths
).discard_output()
1006 def worktree_clean(self
):
1007 """Check whether the worktree is clean relative to index."""
1009 self
.run(['git', 'update-index', '--refresh']).discard_output()
1010 except run
.RunException
:
1015 class Branch(object):
1016 """Represents a Git branch."""
1017 def __init__(self
, repository
, name
):
1018 self
.__repository
= repository
1023 raise BranchException('%s: no such branch' % name
)
1025 name
= property(lambda self
: self
.__name
)
1026 repository
= property(lambda self
: self
.__repository
)
1029 return 'refs/heads/%s' % self
.__name
1032 return self
.__repository
.refs
.get(self
.__ref
())
1033 def set_head(self
, commit
, msg
):
1034 self
.__repository
.refs
.set(self
.__ref
(), commit
, msg
)
1036 def set_parent_remote(self
, name
):
1037 value
= config
.set('branch.%s.remote' % self
.__name
, name
)
1038 def set_parent_branch(self
, name
):
1039 if config
.get('branch.%s.remote' % self
.__name
):
1040 # Never set merge if remote is not set to avoid
1041 # possibly-erroneous lookups into 'origin'
1042 config
.set('branch.%s.merge' % self
.__name
, name
)
1045 def create(cls
, repository
, name
, create_at
= None):
1046 """Create a new Git branch and return the corresponding
1047 L{Branch} object."""
1049 branch
= cls(repository
, name
)
1050 except BranchException
:
1053 raise BranchException('%s: branch already exists' % name
)
1055 cmd
= ['git', 'branch']
1057 cmd
.append(create_at
.sha1
)
1058 repository
.run(['git', 'branch', create_at
.sha1
]).discard_output()
1060 return cls(repository
, name
)
1063 """Return the diffstat of the supplied diff."""
1064 return run
.Run('git', 'apply', '--stat', '--summary'
1065 ).raw_input(diff
).raw_output()
1067 def clone(remote
, local
):
1068 """Clone a remote repository using 'git clone'."""
1069 run
.Run('git', 'clone', remote
, local
).run()