diff --git a/dulwich/line_ending.py b/dulwich/line_ending.py index 1d9e40d8..92341c4f 100644 --- a/dulwich/line_ending.py +++ b/dulwich/line_ending.py @@ -1,274 +1,306 @@ # line_ending.py -- Line ending conversion functions # Copyright (C) 2018-2018 Boris Feld # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """ All line-ending related functions, from conversions to config processing Line-ending normalization is a complex beast. Here is some notes and details about how it seems to work. The normalization is a two-fold process that happens at two moments: - When reading a file from the index and to the working directory. For example when doing a `git clone` or `git checkout` call. We call this process the read filter in this module. - When writing a file to the index from the working directory. For example when doing a `git add` call. We call this process the write filter in this module. +Note that when checking status (getting unstaged changes), whether or not +normalization is done on write depends on whether or not the file in the +working dir has also been normalized on read: + +- For autocrlf=true all files are always normalized on both read and write. +- For autocrlf=input files are only normalized on write if they are newly + "added". Since files which are already committed are not normalized on + checkout into the working tree, they are also left alone when staging + modifications into the index. + One thing to know is that Git does line-ending normalization only on text files. How does Git know that a file is text? We can either mark a file as a text file, a binary file or ask Git to automatically decides. Git has an heuristic to detect if a file is a text file or a binary file. It seems based on the percentage of non-printable characters in files. The code for this heuristic is here: https://git.kernel.org/pub/scm/git/git.git/tree/convert.c#n46 Dulwich have an implementation with a slightly different heuristic, the `is_binary` function in `dulwich.patch`. The binary detection heuristic implementation is close to the one in JGit: https://github.com/eclipse/jgit/blob/f6873ffe522bbc3536969a3a3546bf9a819b92bf/org.eclipse.jgit/src/org/eclipse/jgit/diff/RawText.java#L300 There is multiple variables that impact the normalization. First, a repository can contains a `.gitattributes` file (or more than one...) that can further customize the operation on some file patterns, for example: *.txt text Force all `.txt` files to be treated as text files and to have their lines endings normalized. *.jpg -text Force all `.jpg` files to be treated as binary files and to not have their lines endings converted. *.vcproj text eol=crlf Force all `.vcproj` files to be treated as text files and to have their lines endings converted into `CRLF` in working directory no matter the native EOL of the platform. *.sh text eol=lf Force all `.sh` files to be treated as text files and to have their lines endings converted into `LF` in working directory no matter the native EOL of the platform. If the `eol` attribute is not defined, Git uses the `core.eol` configuration value described later. * text=auto Force all files to be scanned by the text file heuristic detection and to have their line endings normalized in case they are detected as text files. Git also have a obsolete attribute named `crlf` that can be translated to the corresponding text attribute value. Then there are some configuration option (that can be defined at the repository or user level): - core.autocrlf - core.eol `core.autocrlf` is taken into account for all files that doesn't have a `text` attribute defined in `.gitattributes`; it takes three possible values: - `true`: This forces all files on the working directory to have CRLF line-endings in the working directory and convert line-endings to LF when writing to the index. When autocrlf is set to true, eol value is ignored. - `input`: Quite similar to the `true` value but only force the write filter, ie line-ending of new files added to the index will get their line-endings converted to LF. - `false` (default): No normalization is done. `core.eol` is the top-level configuration to define the line-ending to use when applying the read_filer. It takes three possible values: - `lf`: When normalization is done, force line-endings to be `LF` in the working directory. - `crlf`: When normalization is done, force line-endings to be `CRLF` in the working directory. - `native` (default): When normalization is done, force line-endings to be the platform's native line ending. One thing to remember is when line-ending normalization is done on a file, Git always normalize line-ending to `LF` when writing to the index. There are sources that seems to indicate that Git won't do line-ending normalization when a file contains mixed line-endings. I think this logic might be in text / binary detection heuristic but couldn't find it yet. Sources: - https://git-scm.com/docs/git-config#git-config-coreeol - https://git-scm.com/docs/git-config#git-config-coreautocrlf - https://git-scm.com/docs/gitattributes#_checking_out_and_checking_in - https://adaptivepatchwork.com/2012/03/01/mind-the-end-of-your-line/ """ from dulwich.objects import Blob from dulwich.patch import is_binary CRLF = b"\r\n" LF = b"\n" def convert_crlf_to_lf(text_hunk): """Convert CRLF in text hunk into LF Args: text_hunk: A bytes string representing a text hunk Returns: The text hunk with the same type, with CRLF replaced into LF """ return text_hunk.replace(CRLF, LF) def convert_lf_to_crlf(text_hunk): """Convert LF in text hunk into CRLF Args: text_hunk: A bytes string representing a text hunk Returns: The text hunk with the same type, with LF replaced into CRLF """ # TODO find a more efficient way of doing it intermediary = text_hunk.replace(CRLF, LF) return intermediary.replace(LF, CRLF) def get_checkout_filter(core_eol, core_autocrlf, git_attributes): """Returns the correct checkout filter based on the passed arguments""" # TODO this function should process the git_attributes for the path and if # the text attribute is not defined, fallback on the # get_checkout_filter_autocrlf function with the autocrlf value return get_checkout_filter_autocrlf(core_autocrlf) def get_checkin_filter(core_eol, core_autocrlf, git_attributes): """Returns the correct checkin filter based on the passed arguments""" # TODO this function should process the git_attributes for the path and if # the text attribute is not defined, fallback on the # get_checkin_filter_autocrlf function with the autocrlf value return get_checkin_filter_autocrlf(core_autocrlf) def get_checkout_filter_autocrlf(core_autocrlf): """Returns the correct checkout filter base on autocrlf value Args: core_autocrlf: The bytes configuration value of core.autocrlf. Valid values are: b'true', b'false' or b'input'. Returns: Either None if no filter has to be applied or a function accepting a single argument, a binary text hunk """ if core_autocrlf == b"true": return convert_lf_to_crlf return None def get_checkin_filter_autocrlf(core_autocrlf): """Returns the correct checkin filter base on autocrlf value Args: core_autocrlf: The bytes configuration value of core.autocrlf. Valid values are: b'true', b'false' or b'input'. Returns: Either None if no filter has to be applied or a function accepting a single argument, a binary text hunk """ if core_autocrlf == b"true" or core_autocrlf == b"input": return convert_crlf_to_lf # Checking filter should never be `convert_lf_to_crlf` return None class BlobNormalizer(object): """An object to store computation result of which filter to apply based on configuration, gitattributes, path and operation (checkin or checkout) """ def __init__(self, config_stack, gitattributes): self.config_stack = config_stack self.gitattributes = gitattributes # Compute which filters we needs based on parameters try: core_eol = config_stack.get("core", "eol") except KeyError: core_eol = "native" try: core_autocrlf = config_stack.get("core", "autocrlf").lower() except KeyError: core_autocrlf = False self.fallback_read_filter = get_checkout_filter( core_eol, core_autocrlf, self.gitattributes ) self.fallback_write_filter = get_checkin_filter( core_eol, core_autocrlf, self.gitattributes ) def checkin_normalize(self, blob, tree_path): """Normalize a blob during a checkin operation""" if self.fallback_write_filter is not None: return normalize_blob( blob, self.fallback_write_filter, binary_detection=True ) return blob def checkout_normalize(self, blob, tree_path): """Normalize a blob during a checkout operation""" if self.fallback_read_filter is not None: return normalize_blob( blob, self.fallback_read_filter, binary_detection=True ) return blob def normalize_blob(blob, conversion, binary_detection): """Takes a blob as input returns either the original blob if binary_detection is True and the blob content looks like binary, else return a new blob with converted data """ # Read the original blob data = blob.data # If we need to detect if a file is binary and the file is detected as # binary, do not apply the conversion function and return the original # chunked text if binary_detection is True: if is_binary(data): return blob # Now apply the conversion converted_data = conversion(data) new_blob = Blob() new_blob.data = converted_data return new_blob + + +class TreeBlobNormalizer(BlobNormalizer): + def __init__(self, config_stack, git_attributes, object_store, tree=None): + super().__init__(config_stack, git_attributes) + if tree: + self.existing_paths = { + name + for name, _, _ in object_store.iter_tree_contents(tree) + } + else: + self.existing_paths = set() + + def checkin_normalize(self, blob, tree_path): + # Existing files should only be normalized on checkin if it was + # previously normalized on checkout + if ( + self.fallback_read_filter is not None + or tree_path not in self.existing_paths + ): + return super().checkin_normalize(blob, tree_path) + return blob diff --git a/dulwich/repo.py b/dulwich/repo.py index 2f086ffb..6cbdbf8f 100644 --- a/dulwich/repo.py +++ b/dulwich/repo.py @@ -1,1634 +1,1644 @@ # repo.py -- For dealing with git repositories. # Copyright (C) 2007 James Westby # Copyright (C) 2008-2013 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Repository access. This module contains the base class for git repositories (BaseRepo) and an implementation which uses a repository on local disk (Repo). """ from io import BytesIO import os import sys import stat import time from typing import Optional, Tuple, TYPE_CHECKING, List, Dict, Union, Iterable if TYPE_CHECKING: # There are no circular imports here, but we try to defer imports as long # as possible to reduce start-up time for anything that doesn't need # these imports. from dulwich.config import StackedConfig, ConfigFile from dulwich.index import Index from dulwich.errors import ( NoIndexPresent, NotBlobError, NotCommitError, NotGitRepository, NotTreeError, NotTagError, CommitError, RefFormatError, HookError, ) from dulwich.file import ( GitFile, ) from dulwich.object_store import ( DiskObjectStore, MemoryObjectStore, BaseObjectStore, ObjectStoreGraphWalker, ) from dulwich.objects import ( check_hexsha, valid_hexsha, Blob, Commit, ShaFile, Tag, Tree, ) from dulwich.pack import ( pack_objects_to_data, ) from dulwich.hooks import ( Hook, PreCommitShellHook, PostCommitShellHook, CommitMsgShellHook, PostReceiveShellHook, ) -from dulwich.line_ending import BlobNormalizer +from dulwich.line_ending import BlobNormalizer, TreeBlobNormalizer from dulwich.refs import ( # noqa: F401 ANNOTATED_TAG_SUFFIX, check_ref_format, RefsContainer, DictRefsContainer, InfoRefsContainer, DiskRefsContainer, read_packed_refs, read_packed_refs_with_peeled, write_packed_refs, SYMREF, ) import warnings CONTROLDIR = ".git" OBJECTDIR = "objects" REFSDIR = "refs" REFSDIR_TAGS = "tags" REFSDIR_HEADS = "heads" INDEX_FILENAME = "index" COMMONDIR = "commondir" GITDIR = "gitdir" WORKTREES = "worktrees" BASE_DIRECTORIES = [ ["branches"], [REFSDIR], [REFSDIR, REFSDIR_TAGS], [REFSDIR, REFSDIR_HEADS], ["hooks"], ["info"], ] DEFAULT_REF = b"refs/heads/master" class InvalidUserIdentity(Exception): """User identity is not of the format 'user '""" def __init__(self, identity): self.identity = identity def _get_default_identity() -> Tuple[str, str]: import getpass import socket username = getpass.getuser() try: import pwd except ImportError: fullname = None else: try: gecos = pwd.getpwnam(username).pw_gecos except KeyError: fullname = None else: fullname = gecos.split(",")[0] if not fullname: fullname = username email = os.environ.get("EMAIL") if email is None: email = "{}@{}".format(username, socket.gethostname()) return (fullname, email) def get_user_identity(config: "StackedConfig", kind: Optional[str] = None) -> bytes: """Determine the identity to use for new commits. If kind is set, this first checks GIT_${KIND}_NAME and GIT_${KIND}_EMAIL. If those variables are not set, then it will fall back to reading the user.name and user.email settings from the specified configuration. If that also fails, then it will fall back to using the current users' identity as obtained from the host system (e.g. the gecos field, $EMAIL, $USER@$(hostname -f). Args: kind: Optional kind to return identity for, usually either "AUTHOR" or "COMMITTER". Returns: A user identity """ user = None # type: Optional[bytes] email = None # type: Optional[bytes] if kind: user_uc = os.environ.get("GIT_" + kind + "_NAME") if user_uc is not None: user = user_uc.encode("utf-8") email_uc = os.environ.get("GIT_" + kind + "_EMAIL") if email_uc is not None: email = email_uc.encode("utf-8") if user is None: try: user = config.get(("user",), "name") except KeyError: user = None if email is None: try: email = config.get(("user",), "email") except KeyError: email = None default_user, default_email = _get_default_identity() if user is None: user = default_user.encode("utf-8") if email is None: email = default_email.encode("utf-8") if email.startswith(b"<") and email.endswith(b">"): email = email[1:-1] return user + b" <" + email + b">" def check_user_identity(identity): """Verify that a user identity is formatted correctly. Args: identity: User identity bytestring Raises: InvalidUserIdentity: Raised when identity is invalid """ try: fst, snd = identity.split(b" <", 1) except ValueError: raise InvalidUserIdentity(identity) if b">" not in snd: raise InvalidUserIdentity(identity) def parse_graftpoints( graftpoints: Iterable[bytes], ) -> Dict[bytes, List[bytes]]: """Convert a list of graftpoints into a dict Args: graftpoints: Iterator of graftpoint lines Each line is formatted as: []* Resulting dictionary is: : [*] https://git.wiki.kernel.org/index.php/GraftPoint """ grafts = {} for line in graftpoints: raw_graft = line.split(None, 1) commit = raw_graft[0] if len(raw_graft) == 2: parents = raw_graft[1].split() else: parents = [] for sha in [commit] + parents: check_hexsha(sha, "Invalid graftpoint") grafts[commit] = parents return grafts def serialize_graftpoints(graftpoints: Dict[bytes, List[bytes]]) -> bytes: """Convert a dictionary of grafts into string The graft dictionary is: : [*] Each line is formatted as: []* https://git.wiki.kernel.org/index.php/GraftPoint """ graft_lines = [] for commit, parents in graftpoints.items(): if parents: graft_lines.append(commit + b" " + b" ".join(parents)) else: graft_lines.append(commit) return b"\n".join(graft_lines) def _set_filesystem_hidden(path): """Mark path as to be hidden if supported by platform and filesystem. On win32 uses SetFileAttributesW api: """ if sys.platform == "win32": import ctypes from ctypes.wintypes import BOOL, DWORD, LPCWSTR FILE_ATTRIBUTE_HIDDEN = 2 SetFileAttributesW = ctypes.WINFUNCTYPE(BOOL, LPCWSTR, DWORD)( ("SetFileAttributesW", ctypes.windll.kernel32) ) if isinstance(path, bytes): path = os.fsdecode(path) if not SetFileAttributesW(path, FILE_ATTRIBUTE_HIDDEN): pass # Could raise or log `ctypes.WinError()` here # Could implement other platform specific filesytem hiding here class ParentsProvider(object): def __init__(self, store, grafts={}, shallows=[]): self.store = store self.grafts = grafts self.shallows = set(shallows) def get_parents(self, commit_id, commit=None): try: return self.grafts[commit_id] except KeyError: pass if commit_id in self.shallows: return [] if commit is None: commit = self.store[commit_id] return commit.parents class BaseRepo(object): """Base class for a git repository. :ivar object_store: Dictionary-like object for accessing the objects :ivar refs: Dictionary-like object with the refs in this repository """ def __init__(self, object_store: BaseObjectStore, refs: RefsContainer): """Open a repository. This shouldn't be called directly, but rather through one of the base classes, such as MemoryRepo or Repo. Args: object_store: Object store to use refs: Refs container to use """ self.object_store = object_store self.refs = refs self._graftpoints = {} # type: Dict[bytes, List[bytes]] self.hooks = {} # type: Dict[str, Hook] def _determine_file_mode(self) -> bool: """Probe the file-system to determine whether permissions can be trusted. Returns: True if permissions can be trusted, False otherwise. """ raise NotImplementedError(self._determine_file_mode) def _init_files(self, bare: bool) -> None: """Initialize a default set of named files.""" from dulwich.config import ConfigFile self._put_named_file("description", b"Unnamed repository") f = BytesIO() cf = ConfigFile() cf.set("core", "repositoryformatversion", "0") if self._determine_file_mode(): cf.set("core", "filemode", True) else: cf.set("core", "filemode", False) cf.set("core", "bare", bare) cf.set("core", "logallrefupdates", True) cf.write_to_file(f) self._put_named_file("config", f.getvalue()) self._put_named_file(os.path.join("info", "exclude"), b"") def get_named_file(self, path): """Get a file from the control dir with a specific name. Although the filename should be interpreted as a filename relative to the control dir in a disk-based Repo, the object returned need not be pointing to a file in that location. Args: path: The path to the file, relative to the control dir. Returns: An open file object, or None if the file does not exist. """ raise NotImplementedError(self.get_named_file) def _put_named_file(self, path, contents): """Write a file to the control dir with the given name and contents. Args: path: The path to the file, relative to the control dir. contents: A string to write to the file. """ raise NotImplementedError(self._put_named_file) def _del_named_file(self, path): """Delete a file in the contrl directory with the given name.""" raise NotImplementedError(self._del_named_file) def open_index(self): """Open the index for this repository. Raises: NoIndexPresent: If no index is present Returns: The matching `Index` """ raise NotImplementedError(self.open_index) def fetch(self, target, determine_wants=None, progress=None, depth=None): """Fetch objects into another repository. Args: target: The target repository determine_wants: Optional function to determine what refs to fetch. progress: Optional progress function depth: Optional shallow fetch depth Returns: The local refs """ if determine_wants is None: determine_wants = target.object_store.determine_wants_all count, pack_data = self.fetch_pack_data( determine_wants, target.get_graph_walker(), progress=progress, depth=depth, ) target.object_store.add_pack_data(count, pack_data, progress) return self.get_refs() def fetch_pack_data( self, determine_wants, graph_walker, progress, get_tagged=None, depth=None, ): """Fetch the pack data required for a set of revisions. Args: determine_wants: Function that takes a dictionary with heads and returns the list of heads to fetch. graph_walker: Object that can iterate over the list of revisions to fetch and has an "ack" method that will be called to acknowledge that a revision is present. progress: Simple progress function that will be called with updated progress strings. get_tagged: Function that returns a dict of pointed-to sha -> tag sha for including tags. depth: Shallow fetch depth Returns: count and iterator over pack data """ # TODO(jelmer): Fetch pack data directly, don't create objects first. objects = self.fetch_objects( determine_wants, graph_walker, progress, get_tagged, depth=depth ) return pack_objects_to_data(objects) def fetch_objects( self, determine_wants, graph_walker, progress, get_tagged=None, depth=None, ): """Fetch the missing objects required for a set of revisions. Args: determine_wants: Function that takes a dictionary with heads and returns the list of heads to fetch. graph_walker: Object that can iterate over the list of revisions to fetch and has an "ack" method that will be called to acknowledge that a revision is present. progress: Simple progress function that will be called with updated progress strings. get_tagged: Function that returns a dict of pointed-to sha -> tag sha for including tags. depth: Shallow fetch depth Returns: iterator over objects, with __len__ implemented """ if depth not in (None, 0): raise NotImplementedError("depth not supported yet") refs = {} for ref, sha in self.get_refs().items(): try: obj = self.object_store[sha] except KeyError: warnings.warn( "ref %s points at non-present sha %s" % (ref.decode("utf-8", "replace"), sha.decode("ascii")), UserWarning, ) continue else: if isinstance(obj, Tag): refs[ref + ANNOTATED_TAG_SUFFIX] = obj.object[1] refs[ref] = sha wants = determine_wants(refs) if not isinstance(wants, list): raise TypeError("determine_wants() did not return a list") shallows = getattr(graph_walker, "shallow", frozenset()) unshallows = getattr(graph_walker, "unshallow", frozenset()) if wants == []: # TODO(dborowitz): find a way to short-circuit that doesn't change # this interface. if shallows or unshallows: # Do not send a pack in shallow short-circuit path return None return [] # If the graph walker is set up with an implementation that can # ACK/NAK to the wire, it will write data to the client through # this call as a side-effect. haves = self.object_store.find_common_revisions(graph_walker) # Deal with shallow requests separately because the haves do # not reflect what objects are missing if shallows or unshallows: # TODO: filter the haves commits from iter_shas. the specific # commits aren't missing. haves = [] parents_provider = ParentsProvider(self.object_store, shallows=shallows) def get_parents(commit): return parents_provider.get_parents(commit.id, commit) return self.object_store.iter_shas( self.object_store.find_missing_objects( haves, wants, self.get_shallow(), progress, get_tagged, get_parents=get_parents, ) ) def generate_pack_data(self, have, want, progress=None, ofs_delta=None): """Generate pack data objects for a set of wants/haves. Args: have: List of SHA1s of objects that should not be sent want: List of SHA1s of objects that should be sent ofs_delta: Whether OFS deltas can be included progress: Optional progress reporting method """ return self.object_store.generate_pack_data( have, want, shallow=self.get_shallow(), progress=progress, ofs_delta=ofs_delta, ) def get_graph_walker(self, heads=None): """Retrieve a graph walker. A graph walker is used by a remote repository (or proxy) to find out which objects are present in this repository. Args: heads: Repository heads to use (optional) Returns: A graph walker object """ if heads is None: heads = [ sha for sha in self.refs.as_dict(b"refs/heads").values() if sha in self.object_store ] parents_provider = ParentsProvider(self.object_store) return ObjectStoreGraphWalker( heads, parents_provider.get_parents, shallow=self.get_shallow() ) def get_refs(self) -> Dict[bytes, bytes]: """Get dictionary with all refs. Returns: A ``dict`` mapping ref names to SHA1s """ return self.refs.as_dict() def head(self) -> bytes: """Return the SHA1 pointed at by HEAD.""" return self.refs[b"HEAD"] def _get_object(self, sha, cls): assert len(sha) in (20, 40) ret = self.get_object(sha) if not isinstance(ret, cls): if cls is Commit: raise NotCommitError(ret) elif cls is Blob: raise NotBlobError(ret) elif cls is Tree: raise NotTreeError(ret) elif cls is Tag: raise NotTagError(ret) else: raise Exception( "Type invalid: %r != %r" % (ret.type_name, cls.type_name) ) return ret def get_object(self, sha: bytes) -> ShaFile: """Retrieve the object with the specified SHA. Args: sha: SHA to retrieve Returns: A ShaFile object Raises: KeyError: when the object can not be found """ return self.object_store[sha] def parents_provider(self): return ParentsProvider( self.object_store, grafts=self._graftpoints, shallows=self.get_shallow(), ) def get_parents(self, sha: bytes, commit: Commit = None) -> List[bytes]: """Retrieve the parents of a specific commit. If the specific commit is a graftpoint, the graft parents will be returned instead. Args: sha: SHA of the commit for which to retrieve the parents commit: Optional commit matching the sha Returns: List of parents """ return self.parents_provider().get_parents(sha, commit) def get_config(self): """Retrieve the config object. Returns: `ConfigFile` object for the ``.git/config`` file. """ raise NotImplementedError(self.get_config) def get_description(self): """Retrieve the description for this repository. Returns: String with the description of the repository as set by the user. """ raise NotImplementedError(self.get_description) def set_description(self, description): """Set the description for this repository. Args: description: Text to set as description for this repository. """ raise NotImplementedError(self.set_description) def get_config_stack(self) -> "StackedConfig": """Return a config stack for this repository. This stack accesses the configuration for both this repository itself (.git/config) and the global configuration, which usually lives in ~/.gitconfig. Returns: `Config` instance for this repository """ from dulwich.config import StackedConfig backends = [self.get_config()] + StackedConfig.default_backends() return StackedConfig(backends, writable=backends[0]) def get_shallow(self): """Get the set of shallow commits. Returns: Set of shallow commits. """ f = self.get_named_file("shallow") if f is None: return set() with f: return {line.strip() for line in f} def update_shallow(self, new_shallow, new_unshallow): """Update the list of shallow objects. Args: new_shallow: Newly shallow objects new_unshallow: Newly no longer shallow objects """ shallow = self.get_shallow() if new_shallow: shallow.update(new_shallow) if new_unshallow: shallow.difference_update(new_unshallow) if shallow: self._put_named_file( "shallow", b"".join([sha + b"\n" for sha in shallow]) ) else: self._del_named_file("shallow") def get_peeled(self, ref): """Get the peeled value of a ref. Args: ref: The refname to peel. Returns: The fully-peeled SHA1 of a tag object, after peeling all intermediate tags; if the original ref does not point to a tag, this will equal the original SHA1. """ cached = self.refs.get_peeled(ref) if cached is not None: return cached return self.object_store.peel_sha(self.refs[ref]).id def get_walker(self, include=None, *args, **kwargs): """Obtain a walker for this repository. Args: include: Iterable of SHAs of commits to include along with their ancestors. Defaults to [HEAD] exclude: Iterable of SHAs of commits to exclude along with their ancestors, overriding includes. order: ORDER_* constant specifying the order of results. Anything other than ORDER_DATE may result in O(n) memory usage. reverse: If True, reverse the order of output, requiring O(n) memory. max_entries: The maximum number of entries to yield, or None for no limit. paths: Iterable of file or subtree paths to show entries for. rename_detector: diff.RenameDetector object for detecting renames. follow: If True, follow path across renames/copies. Forces a default rename_detector. since: Timestamp to list commits after. until: Timestamp to list commits before. queue_cls: A class to use for a queue of commits, supporting the iterator protocol. The constructor takes a single argument, the Walker. Returns: A `Walker` object """ from dulwich.walk import Walker if include is None: include = [self.head()] if isinstance(include, str): include = [include] kwargs["get_parents"] = lambda commit: self.get_parents(commit.id, commit) return Walker(self.object_store, include, *args, **kwargs) def __getitem__(self, name): """Retrieve a Git object by SHA1 or ref. Args: name: A Git object SHA1 or a ref name Returns: A `ShaFile` object, such as a Commit or Blob Raises: KeyError: when the specified ref or object does not exist """ if not isinstance(name, bytes): raise TypeError( "'name' must be bytestring, not %.80s" % type(name).__name__ ) if len(name) in (20, 40): try: return self.object_store[name] except (KeyError, ValueError): pass try: return self.object_store[self.refs[name]] except RefFormatError: raise KeyError(name) def __contains__(self, name: bytes) -> bool: """Check if a specific Git object or ref is present. Args: name: Git object SHA1 or ref name """ if len(name) == 20 or (len(name) == 40 and valid_hexsha(name)): return name in self.object_store or name in self.refs else: return name in self.refs def __setitem__(self, name: bytes, value: Union[ShaFile, bytes]): """Set a ref. Args: name: ref name value: Ref value - either a ShaFile object, or a hex sha """ if name.startswith(b"refs/") or name == b"HEAD": if isinstance(value, ShaFile): self.refs[name] = value.id elif isinstance(value, bytes): self.refs[name] = value else: raise TypeError(value) else: raise ValueError(name) def __delitem__(self, name: bytes): """Remove a ref. Args: name: Name of the ref to remove """ if name.startswith(b"refs/") or name == b"HEAD": del self.refs[name] else: raise ValueError(name) def _get_user_identity(self, config: "StackedConfig", kind: str = None) -> bytes: """Determine the identity to use for new commits.""" # TODO(jelmer): Deprecate this function in favor of get_user_identity return get_user_identity(config) def _add_graftpoints(self, updated_graftpoints: Dict[bytes, List[bytes]]): """Add or modify graftpoints Args: updated_graftpoints: Dict of commit shas to list of parent shas """ # Simple validation for commit, parents in updated_graftpoints.items(): for sha in [commit] + parents: check_hexsha(sha, "Invalid graftpoint") self._graftpoints.update(updated_graftpoints) def _remove_graftpoints(self, to_remove: List[bytes] = []) -> None: """Remove graftpoints Args: to_remove: List of commit shas """ for sha in to_remove: del self._graftpoints[sha] def _read_heads(self, name): f = self.get_named_file(name) if f is None: return [] with f: return [line.strip() for line in f.readlines() if line.strip()] def do_commit( # noqa: C901 self, message=None, committer=None, author=None, commit_timestamp=None, commit_timezone=None, author_timestamp=None, author_timezone=None, tree=None, encoding=None, ref=b"HEAD", merge_heads=None, no_verify=False, ): """Create a new commit. If not specified, `committer` and `author` default to get_user_identity(..., 'COMMITTER') and get_user_identity(..., 'AUTHOR') respectively. Args: message: Commit message committer: Committer fullname author: Author fullname commit_timestamp: Commit timestamp (defaults to now) commit_timezone: Commit timestamp timezone (defaults to GMT) author_timestamp: Author timestamp (defaults to commit timestamp) author_timezone: Author timestamp timezone (defaults to commit timestamp timezone) tree: SHA1 of the tree root to use (if not specified the current index will be committed). encoding: Encoding ref: Optional ref to commit to (defaults to current branch) merge_heads: Merge heads (defaults to .git/MERGE_HEAD) no_verify: Skip pre-commit and commit-msg hooks Returns: New commit SHA1 """ c = Commit() if tree is None: index = self.open_index() c.tree = index.commit(self.object_store) else: if len(tree) != 40: raise ValueError("tree must be a 40-byte hex sha string") c.tree = tree try: if not no_verify: self.hooks["pre-commit"].execute() except HookError as e: raise CommitError(e) except KeyError: # no hook defined, silent fallthrough pass config = self.get_config_stack() if merge_heads is None: merge_heads = self._read_heads("MERGE_HEAD") if committer is None: committer = get_user_identity(config, kind="COMMITTER") check_user_identity(committer) c.committer = committer if commit_timestamp is None: # FIXME: Support GIT_COMMITTER_DATE environment variable commit_timestamp = time.time() c.commit_time = int(commit_timestamp) if commit_timezone is None: # FIXME: Use current user timezone rather than UTC commit_timezone = 0 c.commit_timezone = commit_timezone if author is None: author = get_user_identity(config, kind="AUTHOR") c.author = author check_user_identity(author) if author_timestamp is None: # FIXME: Support GIT_AUTHOR_DATE environment variable author_timestamp = commit_timestamp c.author_time = int(author_timestamp) if author_timezone is None: author_timezone = commit_timezone c.author_timezone = author_timezone if encoding is None: try: encoding = config.get(("i18n",), "commitEncoding") except KeyError: pass # No dice if encoding is not None: c.encoding = encoding if message is None: # FIXME: Try to read commit message from .git/MERGE_MSG raise ValueError("No commit message specified") try: if no_verify: c.message = message else: c.message = self.hooks["commit-msg"].execute(message) if c.message is None: c.message = message except HookError as e: raise CommitError(e) except KeyError: # no hook defined, message not modified c.message = message if ref is None: # Create a dangling commit c.parents = merge_heads self.object_store.add_object(c) else: try: old_head = self.refs[ref] c.parents = [old_head] + merge_heads self.object_store.add_object(c) ok = self.refs.set_if_equals( ref, old_head, c.id, message=b"commit: " + message, committer=committer, timestamp=commit_timestamp, timezone=commit_timezone, ) except KeyError: c.parents = merge_heads self.object_store.add_object(c) ok = self.refs.add_if_new( ref, c.id, message=b"commit: " + message, committer=committer, timestamp=commit_timestamp, timezone=commit_timezone, ) if not ok: # Fail if the atomic compare-and-swap failed, leaving the # commit and all its objects as garbage. raise CommitError("%s changed during commit" % (ref,)) self._del_named_file("MERGE_HEAD") try: self.hooks["post-commit"].execute() except HookError as e: # silent failure warnings.warn("post-commit hook failed: %s" % e, UserWarning) except KeyError: # no hook defined, silent fallthrough pass return c.id def read_gitfile(f): """Read a ``.git`` file. The first line of the file should start with "gitdir: " Args: f: File-like object to read from Returns: A path """ cs = f.read() if not cs.startswith("gitdir: "): raise ValueError("Expected file to start with 'gitdir: '") return cs[len("gitdir: ") :].rstrip("\n") class UnsupportedVersion(Exception): """Unsupported repository version.""" def __init__(self, version): self.version = version class Repo(BaseRepo): """A git repository backed by local disk. To open an existing repository, call the contructor with the path of the repository. To create a new repository, use the Repo.init class method. """ def __init__(self, root, object_store=None, bare=None): hidden_path = os.path.join(root, CONTROLDIR) if bare is None: if (os.path.isfile(hidden_path) or os.path.isdir(os.path.join(hidden_path, OBJECTDIR))): bare = False elif (os.path.isdir(os.path.join(root, OBJECTDIR)) and os.path.isdir(os.path.join(root, REFSDIR))): bare = True else: raise NotGitRepository( "No git repository was found at %(path)s" % dict(path=root) ) self.bare = bare if bare is False: if os.path.isfile(hidden_path): with open(hidden_path, "r") as f: path = read_gitfile(f) self.bare = False self._controldir = os.path.join(root, path) else: self._controldir = hidden_path else: self._controldir = root commondir = self.get_named_file(COMMONDIR) if commondir is not None: with commondir: self._commondir = os.path.join( self.controldir(), os.fsdecode(commondir.read().rstrip(b"\r\n")), ) else: self._commondir = self._controldir self.path = root config = self.get_config() try: format_version = int(config.get("core", "repositoryformatversion")) except KeyError: format_version = 0 if format_version != 0: raise UnsupportedVersion(format_version) if object_store is None: object_store = DiskObjectStore.from_config( os.path.join(self.commondir(), OBJECTDIR), config ) refs = DiskRefsContainer( self.commondir(), self._controldir, logger=self._write_reflog ) BaseRepo.__init__(self, object_store, refs) self._graftpoints = {} graft_file = self.get_named_file( os.path.join("info", "grafts"), basedir=self.commondir() ) if graft_file: with graft_file: self._graftpoints.update(parse_graftpoints(graft_file)) graft_file = self.get_named_file("shallow", basedir=self.commondir()) if graft_file: with graft_file: self._graftpoints.update(parse_graftpoints(graft_file)) self.hooks["pre-commit"] = PreCommitShellHook(self.controldir()) self.hooks["commit-msg"] = CommitMsgShellHook(self.controldir()) self.hooks["post-commit"] = PostCommitShellHook(self.controldir()) self.hooks["post-receive"] = PostReceiveShellHook(self.controldir()) def _write_reflog( self, ref, old_sha, new_sha, committer, timestamp, timezone, message ): from .reflog import format_reflog_line path = os.path.join(self.controldir(), "logs", os.fsdecode(ref)) try: os.makedirs(os.path.dirname(path)) except FileExistsError: pass if committer is None: config = self.get_config_stack() committer = self._get_user_identity(config) check_user_identity(committer) if timestamp is None: timestamp = int(time.time()) if timezone is None: timezone = 0 # FIXME with open(path, "ab") as f: f.write( format_reflog_line( old_sha, new_sha, committer, timestamp, timezone, message ) + b"\n" ) @classmethod def discover(cls, start="."): """Iterate parent directories to discover a repository Return a Repo object for the first parent directory that looks like a Git repository. Args: start: The directory to start discovery from (defaults to '.') """ remaining = True path = os.path.abspath(start) while remaining: try: return cls(path) except NotGitRepository: path, remaining = os.path.split(path) raise NotGitRepository( "No git repository was found at %(path)s" % dict(path=start) ) def controldir(self): """Return the path of the control directory.""" return self._controldir def commondir(self): """Return the path of the common directory. For a main working tree, it is identical to controldir(). For a linked working tree, it is the control directory of the main working tree.""" return self._commondir def _determine_file_mode(self): """Probe the file-system to determine whether permissions can be trusted. Returns: True if permissions can be trusted, False otherwise. """ fname = os.path.join(self.path, ".probe-permissions") with open(fname, "w") as f: f.write("") st1 = os.lstat(fname) try: os.chmod(fname, st1.st_mode ^ stat.S_IXUSR) except PermissionError: return False st2 = os.lstat(fname) os.unlink(fname) mode_differs = st1.st_mode != st2.st_mode st2_has_exec = (st2.st_mode & stat.S_IXUSR) != 0 return mode_differs and st2_has_exec def _put_named_file(self, path, contents): """Write a file to the control dir with the given name and contents. Args: path: The path to the file, relative to the control dir. contents: A string to write to the file. """ path = path.lstrip(os.path.sep) with GitFile(os.path.join(self.controldir(), path), "wb") as f: f.write(contents) def _del_named_file(self, path): try: os.unlink(os.path.join(self.controldir(), path)) except FileNotFoundError: return def get_named_file(self, path, basedir=None): """Get a file from the control dir with a specific name. Although the filename should be interpreted as a filename relative to the control dir in a disk-based Repo, the object returned need not be pointing to a file in that location. Args: path: The path to the file, relative to the control dir. basedir: Optional argument that specifies an alternative to the control dir. Returns: An open file object, or None if the file does not exist. """ # TODO(dborowitz): sanitize filenames, since this is used directly by # the dumb web serving code. if basedir is None: basedir = self.controldir() path = path.lstrip(os.path.sep) try: return open(os.path.join(basedir, path), "rb") except FileNotFoundError: return None def index_path(self): """Return path to the index file.""" return os.path.join(self.controldir(), INDEX_FILENAME) def open_index(self) -> "Index": """Open the index for this repository. Raises: NoIndexPresent: If no index is present Returns: The matching `Index` """ from dulwich.index import Index if not self.has_index(): raise NoIndexPresent() return Index(self.index_path()) def has_index(self): """Check if an index is present.""" # Bare repos must never have index files; non-bare repos may have a # missing index file, which is treated as empty. return not self.bare def stage(self, fs_paths): """Stage a set of paths. Args: fs_paths: List of paths, relative to the repository path """ root_path_bytes = os.fsencode(self.path) if not isinstance(fs_paths, list): fs_paths = [fs_paths] from dulwich.index import ( blob_from_path_and_stat, index_entry_from_stat, _fs_to_tree_path, ) index = self.open_index() blob_normalizer = self.get_blob_normalizer() for fs_path in fs_paths: if not isinstance(fs_path, bytes): fs_path = os.fsencode(fs_path) if os.path.isabs(fs_path): raise ValueError( "path %r should be relative to " "repository root, not absolute" % fs_path ) tree_path = _fs_to_tree_path(fs_path) full_path = os.path.join(root_path_bytes, fs_path) try: st = os.lstat(full_path) except OSError: # File no longer exists try: del index[tree_path] except KeyError: pass # already removed else: if not stat.S_ISREG(st.st_mode) and not stat.S_ISLNK(st.st_mode): try: del index[tree_path] except KeyError: pass else: blob = blob_from_path_and_stat(full_path, st) blob = blob_normalizer.checkin_normalize(blob, fs_path) self.object_store.add_object(blob) index[tree_path] = index_entry_from_stat(st, blob.id, 0) index.write() def clone( self, target_path, mkdir=True, bare=False, origin=b"origin", checkout=None, ): """Clone this repository. Args: target_path: Target path mkdir: Create the target directory bare: Whether to create a bare repository origin: Base name for refs in target repository cloned from this repository Returns: Created repository as `Repo` """ if not bare: target = self.init(target_path, mkdir=mkdir) else: if checkout: raise ValueError("checkout and bare are incompatible") target = self.init_bare(target_path, mkdir=mkdir) self.fetch(target) encoded_path = self.path if not isinstance(encoded_path, bytes): encoded_path = os.fsencode(encoded_path) ref_message = b"clone: from " + encoded_path target.refs.import_refs( b"refs/remotes/" + origin, self.refs.as_dict(b"refs/heads"), message=ref_message, ) target.refs.import_refs( b"refs/tags", self.refs.as_dict(b"refs/tags"), message=ref_message ) try: target.refs.add_if_new( DEFAULT_REF, self.refs[DEFAULT_REF], message=ref_message ) except KeyError: pass target_config = target.get_config() target_config.set(("remote", "origin"), "url", encoded_path) target_config.set( ("remote", "origin"), "fetch", "+refs/heads/*:refs/remotes/origin/*", ) target_config.write_to_path() # Update target head head_chain, head_sha = self.refs.follow(b"HEAD") if head_chain and head_sha is not None: target.refs.set_symbolic_ref(b"HEAD", head_chain[-1], message=ref_message) target[b"HEAD"] = head_sha if checkout is None: checkout = not bare if checkout: # Checkout HEAD to target dir target.reset_index() return target def reset_index(self, tree=None): """Reset the index back to a specific tree. Args: tree: Tree SHA to reset to, None for current HEAD tree. """ from dulwich.index import ( build_index_from_tree, validate_path_element_default, validate_path_element_ntfs, ) if tree is None: tree = self[b"HEAD"].tree config = self.get_config() honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt") if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"): validate_path_element = validate_path_element_ntfs else: validate_path_element = validate_path_element_default return build_index_from_tree( self.path, self.index_path(), self.object_store, tree, honor_filemode=honor_filemode, validate_path_element=validate_path_element, ) def get_config(self) -> "ConfigFile": """Retrieve the config object. Returns: `ConfigFile` object for the ``.git/config`` file. """ from dulwich.config import ConfigFile path = os.path.join(self._controldir, "config") try: return ConfigFile.from_path(path) except FileNotFoundError: ret = ConfigFile() ret.path = path return ret def get_description(self): """Retrieve the description of this repository. Returns: A string describing the repository or None. """ path = os.path.join(self._controldir, "description") try: with GitFile(path, "rb") as f: return f.read() except FileNotFoundError: return None def __repr__(self): return "" % self.path def set_description(self, description): """Set the description for this repository. Args: description: Text to set as description for this repository. """ self._put_named_file("description", description) @classmethod def _init_maybe_bare(cls, path, controldir, bare, object_store=None): for d in BASE_DIRECTORIES: os.mkdir(os.path.join(controldir, *d)) if object_store is None: object_store = DiskObjectStore.init(os.path.join(controldir, OBJECTDIR)) ret = cls(path, bare=bare, object_store=object_store) ret.refs.set_symbolic_ref(b"HEAD", DEFAULT_REF) ret._init_files(bare) return ret @classmethod def init(cls, path, mkdir=False): """Create a new repository. Args: path: Path in which to create the repository mkdir: Whether to create the directory Returns: `Repo` instance """ if mkdir: os.mkdir(path) controldir = os.path.join(path, CONTROLDIR) os.mkdir(controldir) _set_filesystem_hidden(controldir) return cls._init_maybe_bare(path, controldir, False) @classmethod def _init_new_working_directory(cls, path, main_repo, identifier=None, mkdir=False): """Create a new working directory linked to a repository. Args: path: Path in which to create the working tree. main_repo: Main repository to reference identifier: Worktree identifier mkdir: Whether to create the directory Returns: `Repo` instance """ if mkdir: os.mkdir(path) if identifier is None: identifier = os.path.basename(path) main_worktreesdir = os.path.join(main_repo.controldir(), WORKTREES) worktree_controldir = os.path.join(main_worktreesdir, identifier) gitdirfile = os.path.join(path, CONTROLDIR) with open(gitdirfile, "wb") as f: f.write(b"gitdir: " + os.fsencode(worktree_controldir) + b"\n") try: os.mkdir(main_worktreesdir) except FileExistsError: pass try: os.mkdir(worktree_controldir) except FileExistsError: pass with open(os.path.join(worktree_controldir, GITDIR), "wb") as f: f.write(os.fsencode(gitdirfile) + b"\n") with open(os.path.join(worktree_controldir, COMMONDIR), "wb") as f: f.write(b"../..\n") with open(os.path.join(worktree_controldir, "HEAD"), "wb") as f: f.write(main_repo.head() + b"\n") r = cls(path) r.reset_index() return r @classmethod def init_bare(cls, path, mkdir=False, object_store=None): """Create a new bare repository. ``path`` should already exist and be an empty directory. Args: path: Path to create bare repository in Returns: a `Repo` instance """ if mkdir: os.mkdir(path) return cls._init_maybe_bare(path, path, True, object_store=object_store) create = init_bare def close(self): """Close any files opened by this repository.""" self.object_store.close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() def get_blob_normalizer(self): """Return a BlobNormalizer object""" # TODO Parse the git attributes files git_attributes = {} - return BlobNormalizer(self.get_config_stack(), git_attributes) + config_stack = self.get_config_stack() + try: + tree = self.object_store[self.refs[b"HEAD"]].tree + return TreeBlobNormalizer( + config_stack, + git_attributes, + self.object_store, + tree, + ) + except KeyError: + return BlobNormalizer(config_stack, git_attributes) class MemoryRepo(BaseRepo): """Repo that stores refs, objects, and named files in memory. MemoryRepos are always bare: they have no working tree and no index, since those have a stronger dependency on the filesystem. """ def __init__(self): from dulwich.config import ConfigFile self._reflog = [] refs_container = DictRefsContainer({}, logger=self._append_reflog) BaseRepo.__init__(self, MemoryObjectStore(), refs_container) self._named_files = {} self.bare = True self._config = ConfigFile() self._description = None def _append_reflog(self, *args): self._reflog.append(args) def set_description(self, description): self._description = description def get_description(self): return self._description def _determine_file_mode(self): """Probe the file-system to determine whether permissions can be trusted. Returns: True if permissions can be trusted, False otherwise. """ return sys.platform != "win32" def _put_named_file(self, path, contents): """Write a file to the control dir with the given name and contents. Args: path: The path to the file, relative to the control dir. contents: A string to write to the file. """ self._named_files[path] = contents def _del_named_file(self, path): try: del self._named_files[path] except KeyError: pass def get_named_file(self, path, basedir=None): """Get a file from the control dir with a specific name. Although the filename should be interpreted as a filename relative to the control dir in a disk-baked Repo, the object returned need not be pointing to a file in that location. Args: path: The path to the file, relative to the control dir. Returns: An open file object, or None if the file does not exist. """ contents = self._named_files.get(path, None) if contents is None: return None return BytesIO(contents) def open_index(self): """Fail to open index for this repo, since it is bare. Raises: NoIndexPresent: Raised when no index is present """ raise NoIndexPresent() def get_config(self): """Retrieve the config object. Returns: `ConfigFile` object. """ return self._config @classmethod def init_bare(cls, objects, refs): """Create a new bare repository in memory. Args: objects: Objects for the new repository, as iterable refs: Refs as dictionary, mapping names to object SHA1s """ ret = cls() for obj in objects: ret.object_store.add_object(obj) for refname, sha in refs.items(): ret.refs.add_if_new(refname, sha) ret._init_files(bare=True) return ret diff --git a/dulwich/tests/test_porcelain.py b/dulwich/tests/test_porcelain.py index e6442b73..a2901169 100644 --- a/dulwich/tests/test_porcelain.py +++ b/dulwich/tests/test_porcelain.py @@ -1,2710 +1,2738 @@ # test_porcelain.py -- porcelain tests # Copyright (C) 2013 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Tests for dulwich.porcelain.""" from io import BytesIO, StringIO import os import platform import re import shutil import stat import subprocess import sys import tarfile import tempfile import time from unittest import skipIf from dulwich import porcelain from dulwich.diff_tree import tree_changes from dulwich.errors import CommitError from dulwich.objects import ( Blob, Tag, Tree, ZERO_SHA, ) from dulwich.repo import ( NoIndexPresent, Repo, ) from dulwich.tests import ( TestCase, ) from dulwich.tests.utils import ( build_commit_graph, make_commit, make_object, ) def flat_walk_dir(dir_to_walk): for dirpath, _, filenames in os.walk(dir_to_walk): rel_dirpath = os.path.relpath(dirpath, dir_to_walk) if not dirpath == dir_to_walk: yield rel_dirpath for filename in filenames: if dirpath == dir_to_walk: yield filename else: yield os.path.join(rel_dirpath, filename) class PorcelainTestCase(TestCase): def setUp(self): super(PorcelainTestCase, self).setUp() self.test_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.test_dir) self.repo_path = os.path.join(self.test_dir, "repo") self.repo = Repo.init(self.repo_path, mkdir=True) self.addCleanup(self.repo.close) class PorcelainGpgTestCase(PorcelainTestCase): DEFAULT_KEY = """ -----BEGIN PGP PRIVATE KEY BLOCK----- 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 =YPjc -----END PGP PRIVATE KEY BLOCK----- """ DEFAULT_KEY_ID = "8EB47C310E1F24AE383D832F7CDD800A52E65E26" NON_DEFAULT_KEY = """ -----BEGIN PGP PRIVATE KEY BLOCK----- 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 =kOKh -----END PGP PRIVATE KEY BLOCK----- """ NON_DEFAULT_KEY_ID = "6A93393F50C5E6ACD3D6FB45B936212EDB4E14C0" def setUp(self): super(PorcelainGpgTestCase, self).setUp() self.gpg_dir = os.path.join(self.test_dir, "gpg") os.mkdir(self.gpg_dir, mode=0o700) self.addCleanup(shutil.rmtree, self.gpg_dir) self._old_gnupghome = os.environ.get("GNUPGHOME") os.environ["GNUPGHOME"] = self.gpg_dir if self._old_gnupghome is None: self.addCleanup(os.environ.__delitem__, "GNUPGHOME") else: self.addCleanup(os.environ.__setitem__, "GNUPGHOME", self._old_gnupghome) def import_default_key(self): subprocess.run( ["gpg", "--import"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, input=PorcelainGpgTestCase.DEFAULT_KEY, universal_newlines=True, ) def import_non_default_key(self): subprocess.run( ["gpg", "--import"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, input=PorcelainGpgTestCase.NON_DEFAULT_KEY, universal_newlines=True, ) class ArchiveTests(PorcelainTestCase): """Tests for the archive command.""" def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"refs/heads/master"] = c3.id out = BytesIO() err = BytesIO() porcelain.archive( self.repo.path, b"refs/heads/master", outstream=out, errstream=err ) self.assertEqual(b"", err.getvalue()) tf = tarfile.TarFile(fileobj=out) self.addCleanup(tf.close) self.assertEqual([], tf.getnames()) class UpdateServerInfoTests(PorcelainTestCase): def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"refs/heads/foo"] = c3.id porcelain.update_server_info(self.repo.path) self.assertTrue( os.path.exists(os.path.join(self.repo.controldir(), "info", "refs")) ) class CommitTests(PorcelainTestCase): def test_custom_author(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"refs/heads/foo"] = c3.id sha = porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) self.assertTrue(isinstance(sha, bytes)) self.assertEqual(len(sha), 40) def test_unicode(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"refs/heads/foo"] = c3.id sha = porcelain.commit( self.repo.path, message="Some message", author="Joe ", committer="Bob ", ) self.assertTrue(isinstance(sha, bytes)) self.assertEqual(len(sha), 40) def test_no_verify(self): if os.name != "posix": self.skipTest("shell hook tests requires POSIX shell") self.assertTrue(os.path.exists("/bin/sh")) hooks_dir = os.path.join(self.repo.controldir(), "hooks") os.makedirs(hooks_dir, exist_ok=True) self.addCleanup(shutil.rmtree, hooks_dir) c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) hook_fail = "#!/bin/sh\nexit 1" # hooks are executed in pre-commit, commit-msg order # test commit-msg failure first, then pre-commit failure, then # no_verify to skip both hooks commit_msg = os.path.join(hooks_dir, "commit-msg") with open(commit_msg, "w") as f: f.write(hook_fail) os.chmod(commit_msg, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC) with self.assertRaises(CommitError): porcelain.commit( self.repo.path, message="Some message", author="Joe ", committer="Bob ", ) pre_commit = os.path.join(hooks_dir, "pre-commit") with open(pre_commit, "w") as f: f.write(hook_fail) os.chmod(pre_commit, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC) with self.assertRaises(CommitError): porcelain.commit( self.repo.path, message="Some message", author="Joe ", committer="Bob ", ) sha = porcelain.commit( self.repo.path, message="Some message", author="Joe ", committer="Bob ", no_verify=True, ) self.assertTrue(isinstance(sha, bytes)) self.assertEqual(len(sha), 40) class CleanTests(PorcelainTestCase): def put_files(self, tracked, ignored, untracked, empty_dirs): """Put the described files in the wd""" all_files = tracked | ignored | untracked for file_path in all_files: abs_path = os.path.join(self.repo.path, file_path) # File may need to be written in a dir that doesn't exist yet, so # create the parent dir(s) as necessary parent_dir = os.path.dirname(abs_path) try: os.makedirs(parent_dir) except FileExistsError: pass with open(abs_path, "w") as f: f.write("") with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.writelines(ignored) for dir_path in empty_dirs: os.mkdir(os.path.join(self.repo.path, "empty_dir")) files_to_add = [os.path.join(self.repo.path, t) for t in tracked] porcelain.add(repo=self.repo.path, paths=files_to_add) porcelain.commit(repo=self.repo.path, message="init commit") def assert_wd(self, expected_paths): """Assert paths of files and dirs in wd are same as expected_paths""" control_dir_rel = os.path.relpath(self.repo._controldir, self.repo.path) # normalize paths to simplify comparison across platforms found_paths = { os.path.normpath(p) for p in flat_walk_dir(self.repo.path) if not p.split(os.sep)[0] == control_dir_rel } norm_expected_paths = {os.path.normpath(p) for p in expected_paths} self.assertEqual(found_paths, norm_expected_paths) def test_from_root(self): self.put_files( tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"}, ignored={"ignored_file"}, untracked={ "untracked_file", "tracked_dir/untracked_dir/untracked_file", "untracked_dir/untracked_dir/untracked_file", }, empty_dirs={"empty_dir"}, ) porcelain.clean(repo=self.repo.path, target_dir=self.repo.path) self.assert_wd( { "tracked_file", "tracked_dir/tracked_file", ".gitignore", "ignored_file", "tracked_dir", } ) def test_from_subdir(self): self.put_files( tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"}, ignored={"ignored_file"}, untracked={ "untracked_file", "tracked_dir/untracked_dir/untracked_file", "untracked_dir/untracked_dir/untracked_file", }, empty_dirs={"empty_dir"}, ) porcelain.clean( repo=self.repo, target_dir=os.path.join(self.repo.path, "untracked_dir"), ) self.assert_wd( { "tracked_file", "tracked_dir/tracked_file", ".gitignore", "ignored_file", "untracked_file", "tracked_dir/untracked_dir/untracked_file", "empty_dir", "untracked_dir", "tracked_dir", "tracked_dir/untracked_dir", } ) class CloneTests(PorcelainTestCase): def test_simple_local(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1], [2, 1], [3, 1, 2]] trees = { 1: [(b"f1", f1_1), (b"f2", f1_1)], 2: [(b"f1", f1_1), (b"f2", f1_1)], 3: [(b"f1", f1_1), (b"f2", f1_1)], } c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c3.id self.repo.refs[b"refs/tags/foo"] = c3.id target_path = tempfile.mkdtemp() errstream = BytesIO() self.addCleanup(shutil.rmtree, target_path) r = porcelain.clone( self.repo.path, target_path, checkout=False, errstream=errstream ) self.addCleanup(r.close) self.assertEqual(r.path, target_path) target_repo = Repo(target_path) self.assertEqual(0, len(target_repo.open_index())) self.assertEqual(c3.id, target_repo.refs[b"refs/tags/foo"]) self.assertTrue(b"f1" not in os.listdir(target_path)) self.assertTrue(b"f2" not in os.listdir(target_path)) c = r.get_config() encoded_path = self.repo.path if not isinstance(encoded_path, bytes): encoded_path = encoded_path.encode("utf-8") self.assertEqual(encoded_path, c.get((b"remote", b"origin"), b"url")) self.assertEqual( b"+refs/heads/*:refs/remotes/origin/*", c.get((b"remote", b"origin"), b"fetch"), ) def test_simple_local_with_checkout(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1], [2, 1], [3, 1, 2]] trees = { 1: [(b"f1", f1_1), (b"f2", f1_1)], 2: [(b"f1", f1_1), (b"f2", f1_1)], 3: [(b"f1", f1_1), (b"f2", f1_1)], } c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c3.id target_path = tempfile.mkdtemp() errstream = BytesIO() self.addCleanup(shutil.rmtree, target_path) with porcelain.clone( self.repo.path, target_path, checkout=True, errstream=errstream ) as r: self.assertEqual(r.path, target_path) with Repo(target_path) as r: self.assertEqual(r.head(), c3.id) self.assertTrue("f1" in os.listdir(target_path)) self.assertTrue("f2" in os.listdir(target_path)) def test_bare_local_with_checkout(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1], [2, 1], [3, 1, 2]] trees = { 1: [(b"f1", f1_1), (b"f2", f1_1)], 2: [(b"f1", f1_1), (b"f2", f1_1)], 3: [(b"f1", f1_1), (b"f2", f1_1)], } c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c3.id target_path = tempfile.mkdtemp() errstream = BytesIO() self.addCleanup(shutil.rmtree, target_path) with porcelain.clone( self.repo.path, target_path, bare=True, errstream=errstream ) as r: self.assertEqual(r.path, target_path) with Repo(target_path) as r: r.head() self.assertRaises(NoIndexPresent, r.open_index) self.assertFalse(b"f1" in os.listdir(target_path)) self.assertFalse(b"f2" in os.listdir(target_path)) def test_no_checkout_with_bare(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1]] trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]} (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c1.id self.repo.refs[b"HEAD"] = c1.id target_path = tempfile.mkdtemp() errstream = BytesIO() self.addCleanup(shutil.rmtree, target_path) self.assertRaises( porcelain.Error, porcelain.clone, self.repo.path, target_path, checkout=True, bare=True, errstream=errstream, ) def test_no_head_no_checkout(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1]] trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]} (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c1.id target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, target_path) errstream = BytesIO() r = porcelain.clone( self.repo.path, target_path, checkout=True, errstream=errstream ) r.close() def test_no_head_no_checkout_outstream_errstream_autofallback(self): f1_1 = make_object(Blob, data=b"f1") commit_spec = [[1]] trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]} (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees) self.repo.refs[b"refs/heads/master"] = c1.id target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, target_path) errstream = porcelain.NoneStream() r = porcelain.clone( self.repo.path, target_path, checkout=True, errstream=errstream ) r.close() def test_source_broken(self): target_path = tempfile.mkdtemp() self.assertRaises(Exception, porcelain.clone, "/nonexistant/repo", target_path) self.assertFalse(os.path.exists(target_path)) def test_fetch_symref(self): f1_1 = make_object(Blob, data=b"f1") trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]} [c1] = build_commit_graph(self.repo.object_store, [[1]], trees) self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/else") self.repo.refs[b"refs/heads/else"] = c1.id target_path = tempfile.mkdtemp() errstream = BytesIO() self.addCleanup(shutil.rmtree, target_path) r = porcelain.clone( self.repo.path, target_path, checkout=False, errstream=errstream ) self.addCleanup(r.close) self.assertEqual(r.path, target_path) target_repo = Repo(target_path) self.assertEqual(0, len(target_repo.open_index())) self.assertEqual(c1.id, target_repo.refs[b"refs/heads/else"]) self.assertEqual(c1.id, target_repo.refs[b"HEAD"]) self.assertEqual({b"HEAD": b"refs/heads/else"}, target_repo.refs.get_symrefs()) class InitTests(TestCase): def test_non_bare(self): repo_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, repo_dir) porcelain.init(repo_dir) def test_bare(self): repo_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, repo_dir) porcelain.init(repo_dir, bare=True) class AddTests(PorcelainTestCase): def test_add_default_paths(self): # create a file for initial commit fullpath = os.path.join(self.repo.path, "blah") with open(fullpath, "w") as f: f.write("\n") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( repo=self.repo.path, message=b"test", author=b"test ", committer=b"test ", ) # Add a second test file and a file in a directory with open(os.path.join(self.repo.path, "foo"), "w") as f: f.write("\n") os.mkdir(os.path.join(self.repo.path, "adir")) with open(os.path.join(self.repo.path, "adir", "afile"), "w") as f: f.write("\n") cwd = os.getcwd() try: os.chdir(self.repo.path) self.assertEqual(set(["foo", "blah", "adir", ".git"]), set(os.listdir("."))) self.assertEqual( (["foo", os.path.join("adir", "afile")], set()), porcelain.add(self.repo.path), ) finally: os.chdir(cwd) # Check that foo was added and nothing in .git was modified index = self.repo.open_index() self.assertEqual(sorted(index), [b"adir/afile", b"blah", b"foo"]) def test_add_default_paths_subdir(self): os.mkdir(os.path.join(self.repo.path, "foo")) with open(os.path.join(self.repo.path, "blah"), "w") as f: f.write("\n") with open(os.path.join(self.repo.path, "foo", "blie"), "w") as f: f.write("\n") cwd = os.getcwd() try: os.chdir(os.path.join(self.repo.path, "foo")) porcelain.add(repo=self.repo.path) porcelain.commit( repo=self.repo.path, message=b"test", author=b"test ", committer=b"test ", ) finally: os.chdir(cwd) index = self.repo.open_index() self.assertEqual(sorted(index), [b"foo/blie"]) def test_add_file(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(self.repo.path, paths=[fullpath]) self.assertIn(b"foo", self.repo.open_index()) def test_add_ignored(self): with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("foo\nsubdir/") with open(os.path.join(self.repo.path, "foo"), "w") as f: f.write("BAR") with open(os.path.join(self.repo.path, "bar"), "w") as f: f.write("BAR") os.mkdir(os.path.join(self.repo.path, "subdir")) with open(os.path.join(self.repo.path, "subdir", "baz"), "w") as f: f.write("BAZ") (added, ignored) = porcelain.add( self.repo.path, paths=[ os.path.join(self.repo.path, "foo"), os.path.join(self.repo.path, "bar"), os.path.join(self.repo.path, "subdir"), ], ) self.assertIn(b"bar", self.repo.open_index()) self.assertEqual(set(["bar"]), set(added)) self.assertEqual(set(["foo", os.path.join("subdir", "")]), ignored) def test_add_file_absolute_path(self): # Absolute paths are (not yet) supported with open(os.path.join(self.repo.path, "foo"), "w") as f: f.write("BAR") porcelain.add(self.repo, paths=[os.path.join(self.repo.path, "foo")]) self.assertIn(b"foo", self.repo.open_index()) def test_add_not_in_repo(self): with open(os.path.join(self.test_dir, "foo"), "w") as f: f.write("BAR") self.assertRaises( ValueError, porcelain.add, self.repo, paths=[os.path.join(self.test_dir, "foo")], ) self.assertRaises( (ValueError, FileNotFoundError), porcelain.add, self.repo, paths=["../foo"], ) self.assertEqual([], list(self.repo.open_index())) def test_add_file_clrf_conversion(self): # Set the right configuration to the repo c = self.repo.get_config() c.set("core", "autocrlf", "input") c.write_to_path() # Add a file with CRLF line-ending fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "wb") as f: f.write(b"line1\r\nline2") porcelain.add(self.repo.path, paths=[fullpath]) # The line-endings should have been converted to LF index = self.repo.open_index() self.assertIn(b"foo", index) entry = index[b"foo"] blob = self.repo[entry.sha] self.assertEqual(blob.data, b"line1\nline2") class RemoveTests(PorcelainTestCase): def test_remove_file(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(self.repo.path, paths=[fullpath]) porcelain.commit( repo=self.repo, message=b"test", author=b"test ", committer=b"test ", ) self.assertTrue(os.path.exists(os.path.join(self.repo.path, "foo"))) cwd = os.getcwd() try: os.chdir(self.repo.path) porcelain.remove(self.repo.path, paths=["foo"]) finally: os.chdir(cwd) self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo"))) def test_remove_file_staged(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") cwd = os.getcwd() try: os.chdir(self.repo.path) porcelain.add(self.repo.path, paths=[fullpath]) self.assertRaises(Exception, porcelain.rm, self.repo.path, paths=["foo"]) finally: os.chdir(cwd) def test_remove_file_removed_on_disk(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(self.repo.path, paths=[fullpath]) cwd = os.getcwd() try: os.chdir(self.repo.path) os.remove(fullpath) porcelain.remove(self.repo.path, paths=["foo"]) finally: os.chdir(cwd) self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo"))) class LogTests(PorcelainTestCase): def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id outstream = StringIO() porcelain.log(self.repo.path, outstream=outstream) self.assertEqual(3, outstream.getvalue().count("-" * 50)) def test_max_entries(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id outstream = StringIO() porcelain.log(self.repo.path, outstream=outstream, max_entries=1) self.assertEqual(1, outstream.getvalue().count("-" * 50)) class ShowTests(PorcelainTestCase): def test_nolist(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id outstream = StringIO() porcelain.show(self.repo.path, objects=c3.id, outstream=outstream) self.assertTrue(outstream.getvalue().startswith("-" * 50)) def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id outstream = StringIO() porcelain.show(self.repo.path, objects=[c3.id], outstream=outstream) self.assertTrue(outstream.getvalue().startswith("-" * 50)) def test_blob(self): b = Blob.from_string(b"The Foo\n") self.repo.object_store.add_object(b) outstream = StringIO() porcelain.show(self.repo.path, objects=[b.id], outstream=outstream) self.assertEqual(outstream.getvalue(), "The Foo\n") def test_commit_no_parent(self): a = Blob.from_string(b"The Foo\n") ta = Tree() ta.add(b"somename", 0o100644, a.id) ca = make_commit(tree=ta.id) self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)]) outstream = StringIO() porcelain.show(self.repo.path, objects=[ca.id], outstream=outstream) self.assertMultiLineEqual( outstream.getvalue(), """\ -------------------------------------------------- commit: 344da06c1bb85901270b3e8875c988a027ec087d Author: Test Author Committer: Test Committer Date: Fri Jan 01 2010 00:00:00 +0000 Test message. diff --git a/somename b/somename new file mode 100644 index 0000000..ea5c7bf --- /dev/null +++ b/somename @@ -0,0 +1 @@ +The Foo """, ) def test_tag(self): a = Blob.from_string(b"The Foo\n") ta = Tree() ta.add(b"somename", 0o100644, a.id) ca = make_commit(tree=ta.id) self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)]) porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, objectish=ca.id, tag_time=1552854211, tag_timezone=0, ) outstream = StringIO() porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream) self.maxDiff = None self.assertMultiLineEqual( outstream.getvalue(), """\ Tagger: foo Date: Sun Mar 17 2019 20:23:31 +0000 bar -------------------------------------------------- commit: 344da06c1bb85901270b3e8875c988a027ec087d Author: Test Author Committer: Test Committer Date: Fri Jan 01 2010 00:00:00 +0000 Test message. diff --git a/somename b/somename new file mode 100644 index 0000000..ea5c7bf --- /dev/null +++ b/somename @@ -0,0 +1 @@ +The Foo """, ) def test_commit_with_change(self): a = Blob.from_string(b"The Foo\n") ta = Tree() ta.add(b"somename", 0o100644, a.id) ca = make_commit(tree=ta.id) b = Blob.from_string(b"The Bar\n") tb = Tree() tb.add(b"somename", 0o100644, b.id) cb = make_commit(tree=tb.id, parents=[ca.id]) self.repo.object_store.add_objects( [ (a, None), (b, None), (ta, None), (tb, None), (ca, None), (cb, None), ] ) outstream = StringIO() porcelain.show(self.repo.path, objects=[cb.id], outstream=outstream) self.assertMultiLineEqual( outstream.getvalue(), """\ -------------------------------------------------- commit: 2c6b6c9cb72c130956657e1fdae58e5b103744fa Author: Test Author Committer: Test Committer Date: Fri Jan 01 2010 00:00:00 +0000 Test message. diff --git a/somename b/somename index ea5c7bf..fd38bcb 100644 --- a/somename +++ b/somename @@ -1 +1 @@ -The Foo +The Bar """, ) class SymbolicRefTests(PorcelainTestCase): def test_set_wrong_symbolic_ref(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id self.assertRaises( porcelain.Error, porcelain.symbolic_ref, self.repo.path, b"foobar" ) def test_set_force_wrong_symbolic_ref(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id porcelain.symbolic_ref(self.repo.path, b"force_foobar", force=True) # test if we actually changed the file with self.repo.get_named_file("HEAD") as f: new_ref = f.read() self.assertEqual(new_ref, b"ref: refs/heads/force_foobar\n") def test_set_symbolic_ref(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id porcelain.symbolic_ref(self.repo.path, b"master") def test_set_symbolic_ref_other_than_master(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]], attrs=dict(refs="develop"), ) self.repo.refs[b"HEAD"] = c3.id self.repo.refs[b"refs/heads/develop"] = c3.id porcelain.symbolic_ref(self.repo.path, b"develop") # test if we actually changed the file with self.repo.get_named_file("HEAD") as f: new_ref = f.read() self.assertEqual(new_ref, b"ref: refs/heads/develop\n") class DiffTreeTests(PorcelainTestCase): def test_empty(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id outstream = BytesIO() porcelain.diff_tree(self.repo.path, c2.tree, c3.tree, outstream=outstream) self.assertEqual(outstream.getvalue(), b"") class CommitTreeTests(PorcelainTestCase): def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) b = Blob() b.data = b"foo the bar" t = Tree() t.add(b"somename", 0o100644, b.id) self.repo.object_store.add_object(t) self.repo.object_store.add_object(b) sha = porcelain.commit_tree( self.repo.path, t.id, message=b"Withcommit.", author=b"Joe ", committer=b"Jane ", ) self.assertTrue(isinstance(sha, bytes)) self.assertEqual(len(sha), 40) class RevListTests(PorcelainTestCase): def test_simple(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) outstream = BytesIO() porcelain.rev_list(self.repo.path, [c3.id], outstream=outstream) self.assertEqual( c3.id + b"\n" + c2.id + b"\n" + c1.id + b"\n", outstream.getvalue() ) @skipIf(platform.python_implementation() == "PyPy" or sys.platform == "win32", "gpgme not easily available or supported on Windows and PyPy") class TagCreateSignTests(PorcelainGpgTestCase): def test_default_key(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id cfg = self.repo.get_config() cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID) self.import_default_key() porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, sign=True, ) tags = self.repo.refs.as_dict(b"refs/tags") self.assertEqual(list(tags.keys()), [b"tryme"]) tag = self.repo[b"refs/tags/tryme"] self.assertTrue(isinstance(tag, Tag)) self.assertEqual(b"foo ", tag.tagger) self.assertEqual(b"bar\n", tag.message) self.assertLess(time.time() - tag.tag_time, 5) # GPG Signatures aren't deterministic, so we can't do a static assertion. # Instead we need to check the signature can be verified by git tag = self.repo[b'refs/tags/tryme'] # TODO(jelmer): Remove calls to C git, and call tag.verify() instead - # perhaps moving git call to compat testsuite? subprocess.run( ["git", "--git-dir={}".format(self.repo.controldir()), "tag", "-v", "tryme"], check=True, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) def test_non_default_key(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id cfg = self.repo.get_config() cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID) self.import_non_default_key() porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, sign=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID, ) tags = self.repo.refs.as_dict(b"refs/tags") self.assertEqual(list(tags.keys()), [b"tryme"]) tag = self.repo[b"refs/tags/tryme"] self.assertTrue(isinstance(tag, Tag)) self.assertEqual(b"foo ", tag.tagger) self.assertEqual(b"bar\n", tag.message) self.assertLess(time.time() - tag.tag_time, 5) tag = self.repo[b'refs/tags/tryme'] # GPG Signatures aren't deterministic, so we can't do a static assertion. # Instead we need to check the signature can be verified by git # TODO(jelmer): Remove calls to C git, and call tag.verify() instead - # perhaps moving git call to compat testsuite? subprocess.run( ["git", "--git-dir={}".format(self.repo.controldir()), "tag", "-v", "tryme"], check=True, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) class TagCreateTests(PorcelainTestCase): def test_annotated(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, ) tags = self.repo.refs.as_dict(b"refs/tags") self.assertEqual(list(tags.keys()), [b"tryme"]) tag = self.repo[b"refs/tags/tryme"] self.assertTrue(isinstance(tag, Tag)) self.assertEqual(b"foo ", tag.tagger) self.assertEqual(b"bar\n", tag.message) self.assertLess(time.time() - tag.tag_time, 5) def test_unannotated(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id porcelain.tag_create(self.repo.path, b"tryme", annotated=False) tags = self.repo.refs.as_dict(b"refs/tags") self.assertEqual(list(tags.keys()), [b"tryme"]) self.repo[b"refs/tags/tryme"] self.assertEqual(list(tags.values()), [self.repo.head()]) def test_unannotated_unicode(self): c1, c2, c3 = build_commit_graph( self.repo.object_store, [[1], [2, 1], [3, 1, 2]] ) self.repo.refs[b"HEAD"] = c3.id porcelain.tag_create(self.repo.path, "tryme", annotated=False) tags = self.repo.refs.as_dict(b"refs/tags") self.assertEqual(list(tags.keys()), [b"tryme"]) self.repo[b"refs/tags/tryme"] self.assertEqual(list(tags.values()), [self.repo.head()]) class TagListTests(PorcelainTestCase): def test_empty(self): tags = porcelain.tag_list(self.repo.path) self.assertEqual([], tags) def test_simple(self): self.repo.refs[b"refs/tags/foo"] = b"aa" * 20 self.repo.refs[b"refs/tags/bar/bla"] = b"bb" * 20 tags = porcelain.tag_list(self.repo.path) self.assertEqual([b"bar/bla", b"foo"], tags) class TagDeleteTests(PorcelainTestCase): def test_simple(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.tag_create(self.repo, b"foo") self.assertTrue(b"foo" in porcelain.tag_list(self.repo)) porcelain.tag_delete(self.repo, b"foo") self.assertFalse(b"foo" in porcelain.tag_list(self.repo)) class ResetTests(PorcelainTestCase): def test_hard_head(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some message", committer=b"Jane ", author=b"John ", ) with open(os.path.join(self.repo.path, "foo"), "wb") as f: f.write(b"OOH") porcelain.reset(self.repo, "hard", b"HEAD") index = self.repo.open_index() changes = list( tree_changes( self.repo, index.commit(self.repo.object_store), self.repo[b"HEAD"].tree, ) ) self.assertEqual([], changes) def test_hard_commit(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(self.repo.path, paths=[fullpath]) sha = porcelain.commit( self.repo.path, message=b"Some message", committer=b"Jane ", author=b"John ", ) with open(fullpath, "wb") as f: f.write(b"BAZ") porcelain.add(self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some other message", committer=b"Jane ", author=b"John ", ) porcelain.reset(self.repo, "hard", sha) index = self.repo.open_index() changes = list( tree_changes( self.repo, index.commit(self.repo.object_store), self.repo[sha].tree, ) ) self.assertEqual([], changes) class PushTests(PorcelainTestCase): def test_simple(self): """ Basic test of porcelain push where self.repo is the remote. First clone the remote, commit a file to the clone, then push the changes back to the remote. """ outstream = BytesIO() errstream = BytesIO() porcelain.commit( repo=self.repo.path, message=b"init", author=b"author ", committer=b"committer ", ) # Setup target repo cloned from temp test repo clone_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, clone_path) target_repo = porcelain.clone( self.repo.path, target=clone_path, errstream=errstream ) try: self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"]) finally: target_repo.close() # create a second file to be pushed back to origin handle, fullpath = tempfile.mkstemp(dir=clone_path) os.close(handle) porcelain.add(repo=clone_path, paths=[fullpath]) porcelain.commit( repo=clone_path, message=b"push", author=b"author ", committer=b"committer ", ) # Setup a non-checked out branch in the remote refs_path = b"refs/heads/foo" new_id = self.repo[b"HEAD"].id self.assertNotEqual(new_id, ZERO_SHA) self.repo.refs[refs_path] = new_id # Push to the remote porcelain.push( clone_path, "origin", b"HEAD:" + refs_path, outstream=outstream, errstream=errstream, ) self.assertEqual( target_repo.refs[b"refs/remotes/origin/foo"], target_repo.refs[b"HEAD"], ) # Check that the target and source with Repo(clone_path) as r_clone: self.assertEqual( { b"HEAD": new_id, b"refs/heads/foo": r_clone[b"HEAD"].id, b"refs/heads/master": new_id, }, self.repo.get_refs(), ) self.assertEqual(r_clone[b"HEAD"].id, self.repo[refs_path].id) # Get the change in the target repo corresponding to the add # this will be in the foo branch. change = list( tree_changes( self.repo, self.repo[b"HEAD"].tree, self.repo[b"refs/heads/foo"].tree, ) )[0] self.assertEqual( os.path.basename(fullpath), change.new.path.decode("ascii") ) def test_local_missing(self): """Pushing a new branch.""" outstream = BytesIO() errstream = BytesIO() # Setup target repo cloned from temp test repo clone_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, clone_path) target_repo = porcelain.init(clone_path) target_repo.close() self.assertRaises( porcelain.Error, porcelain.push, self.repo, clone_path, b"HEAD:refs/heads/master", outstream=outstream, errstream=errstream, ) def test_new(self): """Pushing a new branch.""" outstream = BytesIO() errstream = BytesIO() # Setup target repo cloned from temp test repo clone_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, clone_path) target_repo = porcelain.init(clone_path) target_repo.close() # create a second file to be pushed back to origin handle, fullpath = tempfile.mkstemp(dir=clone_path) os.close(handle) porcelain.add(repo=clone_path, paths=[fullpath]) new_id = porcelain.commit( repo=self.repo, message=b"push", author=b"author ", committer=b"committer ", ) # Push to the remote porcelain.push( self.repo, clone_path, b"HEAD:refs/heads/master", outstream=outstream, errstream=errstream, ) with Repo(clone_path) as r_clone: self.assertEqual( { b"HEAD": new_id, b"refs/heads/master": new_id, }, r_clone.get_refs(), ) def test_delete(self): """Basic test of porcelain push, removing a branch.""" outstream = BytesIO() errstream = BytesIO() porcelain.commit( repo=self.repo.path, message=b"init", author=b"author ", committer=b"committer ", ) # Setup target repo cloned from temp test repo clone_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, clone_path) target_repo = porcelain.clone( self.repo.path, target=clone_path, errstream=errstream ) target_repo.close() # Setup a non-checked out branch in the remote refs_path = b"refs/heads/foo" new_id = self.repo[b"HEAD"].id self.assertNotEqual(new_id, ZERO_SHA) self.repo.refs[refs_path] = new_id # Push to the remote porcelain.push( clone_path, self.repo.path, b":" + refs_path, outstream=outstream, errstream=errstream, ) self.assertEqual( { b"HEAD": new_id, b"refs/heads/master": new_id, }, self.repo.get_refs(), ) def test_diverged(self): outstream = BytesIO() errstream = BytesIO() porcelain.commit( repo=self.repo.path, message=b"init", author=b"author ", committer=b"committer ", ) # Setup target repo cloned from temp test repo clone_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, clone_path) target_repo = porcelain.clone( self.repo.path, target=clone_path, errstream=errstream ) target_repo.close() remote_id = porcelain.commit( repo=self.repo.path, message=b"remote change", author=b"author ", committer=b"committer ", ) local_id = porcelain.commit( repo=clone_path, message=b"local change", author=b"author ", committer=b"committer ", ) outstream = BytesIO() errstream = BytesIO() # Push to the remote self.assertRaises( porcelain.DivergedBranches, porcelain.push, clone_path, self.repo.path, b"refs/heads/master", outstream=outstream, errstream=errstream, ) self.assertEqual( { b"HEAD": remote_id, b"refs/heads/master": remote_id, }, self.repo.get_refs(), ) self.assertEqual(b"", outstream.getvalue()) self.assertEqual(b"", errstream.getvalue()) outstream = BytesIO() errstream = BytesIO() # Push to the remote with --force porcelain.push( clone_path, self.repo.path, b"refs/heads/master", outstream=outstream, errstream=errstream, force=True, ) self.assertEqual( { b"HEAD": local_id, b"refs/heads/master": local_id, }, self.repo.get_refs(), ) self.assertEqual(b"", outstream.getvalue()) self.assertTrue(re.match(b"Push to .* successful.\n", errstream.getvalue())) class PullTests(PorcelainTestCase): def setUp(self): super(PullTests, self).setUp() # create a file for initial commit handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test", author=b"test ", committer=b"test ", ) # Setup target repo self.target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.target_path) target_repo = porcelain.clone( self.repo.path, target=self.target_path, errstream=BytesIO() ) target_repo.close() # create a second file to be pushed handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test2", author=b"test2 ", committer=b"test2 ", ) self.assertIn(b"refs/heads/master", self.repo.refs) self.assertIn(b"refs/heads/master", target_repo.refs) def test_simple(self): outstream = BytesIO() errstream = BytesIO() # Pull changes into the cloned repo porcelain.pull( self.target_path, self.repo.path, b"refs/heads/master", outstream=outstream, errstream=errstream, ) # Check the target repo for pushed changes with Repo(self.target_path) as r: self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id) def test_diverged(self): outstream = BytesIO() errstream = BytesIO() c3a = porcelain.commit( repo=self.target_path, message=b"test3a", author=b"test2 ", committer=b"test2 ", ) porcelain.commit( repo=self.repo.path, message=b"test3b", author=b"test2 ", committer=b"test2 ", ) # Pull changes into the cloned repo self.assertRaises( porcelain.DivergedBranches, porcelain.pull, self.target_path, self.repo.path, b"refs/heads/master", outstream=outstream, errstream=errstream, ) # Check the target repo for pushed changes with Repo(self.target_path) as r: self.assertEqual(r[b"refs/heads/master"].id, c3a) self.assertRaises( NotImplementedError, porcelain.pull, self.target_path, self.repo.path, b"refs/heads/master", outstream=outstream, errstream=errstream, fast_forward=False, ) # Check the target repo for pushed changes with Repo(self.target_path) as r: self.assertEqual(r[b"refs/heads/master"].id, c3a) def test_no_refspec(self): outstream = BytesIO() errstream = BytesIO() # Pull changes into the cloned repo porcelain.pull( self.target_path, self.repo.path, outstream=outstream, errstream=errstream, ) # Check the target repo for pushed changes with Repo(self.target_path) as r: self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id) def test_no_remote_location(self): outstream = BytesIO() errstream = BytesIO() # Pull changes into the cloned repo porcelain.pull( self.target_path, refspecs=b"refs/heads/master", outstream=outstream, errstream=errstream, ) # Check the target repo for pushed changes with Repo(self.target_path) as r: self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id) class StatusTests(PorcelainTestCase): def test_empty(self): results = porcelain.status(self.repo) self.assertEqual({"add": [], "delete": [], "modify": []}, results.staged) self.assertEqual([], results.unstaged) def test_status_base(self): """Integration test for `status` functionality.""" # Commit a dummy file then modify it fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("origstuff") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) # modify access and modify time of path os.utime(fullpath, (0, 0)) with open(fullpath, "wb") as f: f.write(b"stuff") # Make a dummy file and stage it filename_add = "bar" fullpath = os.path.join(self.repo.path, filename_add) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) results = porcelain.status(self.repo) self.assertEqual(results.staged["add"][0], filename_add.encode("ascii")) self.assertEqual(results.unstaged, [b"foo"]) def test_status_all(self): del_path = os.path.join(self.repo.path, "foo") mod_path = os.path.join(self.repo.path, "bar") add_path = os.path.join(self.repo.path, "baz") us_path = os.path.join(self.repo.path, "blye") ut_path = os.path.join(self.repo.path, "blyat") with open(del_path, "w") as f: f.write("origstuff") with open(mod_path, "w") as f: f.write("origstuff") with open(us_path, "w") as f: f.write("origstuff") porcelain.add(repo=self.repo.path, paths=[del_path, mod_path, us_path]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) porcelain.remove(self.repo.path, [del_path]) with open(add_path, "w") as f: f.write("origstuff") with open(mod_path, "w") as f: f.write("more_origstuff") with open(us_path, "w") as f: f.write("more_origstuff") porcelain.add(repo=self.repo.path, paths=[add_path, mod_path]) with open(us_path, "w") as f: f.write("\norigstuff") with open(ut_path, "w") as f: f.write("origstuff") results = porcelain.status(self.repo.path) self.assertDictEqual( {"add": [b"baz"], "delete": [b"foo"], "modify": [b"bar"]}, results.staged, ) self.assertListEqual(results.unstaged, [b"blye"]) self.assertListEqual(results.untracked, ["blyat"]) def test_status_crlf_mismatch(self): # First make a commit as if the file has been added on a Linux system # or with core.autocrlf=True file_path = os.path.join(self.repo.path, "crlf") with open(file_path, "wb") as f: f.write(b"line1\nline2") porcelain.add(repo=self.repo.path, paths=[file_path]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) # Then update the file as if it was created by CGit on a Windows # system with core.autocrlf=true with open(file_path, "wb") as f: f.write(b"line1\r\nline2") results = porcelain.status(self.repo) self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged) self.assertListEqual(results.unstaged, [b"crlf"]) self.assertListEqual(results.untracked, []) - def test_status_crlf_convert(self): + def test_status_autocrlf_true(self): # First make a commit as if the file has been added on a Linux system # or with core.autocrlf=True file_path = os.path.join(self.repo.path, "crlf") with open(file_path, "wb") as f: f.write(b"line1\nline2") porcelain.add(repo=self.repo.path, paths=[file_path]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) # Then update the file as if it was created by CGit on a Windows # system with core.autocrlf=true with open(file_path, "wb") as f: f.write(b"line1\r\nline2") # TODO: It should be set automatically by looking at the configuration c = self.repo.get_config() c.set("core", "autocrlf", True) c.write_to_path() results = porcelain.status(self.repo) self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged) self.assertListEqual(results.unstaged, []) self.assertListEqual(results.untracked, []) + def test_status_autocrlf_input(self): + # Commit existing file with CRLF + file_path = os.path.join(self.repo.path, "crlf-exists") + with open(file_path, "wb") as f: + f.write(b"line1\r\nline2") + porcelain.add(repo=self.repo.path, paths=[file_path]) + porcelain.commit( + repo=self.repo.path, + message=b"test status", + author=b"author ", + committer=b"committer ", + ) + + c = self.repo.get_config() + c.set("core", "autocrlf", "input") + c.write_to_path() + + # Add new (untracked) file + file_path = os.path.join(self.repo.path, "crlf-new") + with open(file_path, "wb") as f: + f.write(b"line1\r\nline2") + porcelain.add(repo=self.repo.path, paths=[file_path]) + + results = porcelain.status(self.repo) + self.assertDictEqual({"add": [b"crlf-new"], "delete": [], "modify": []}, results.staged) + self.assertListEqual(results.unstaged, []) + self.assertListEqual(results.untracked, []) + def test_get_tree_changes_add(self): """Unit test for get_tree_changes add.""" # Make a dummy file, stage filename = "bar" fullpath = os.path.join(self.repo.path, filename) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) filename = "foo" fullpath = os.path.join(self.repo.path, filename) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) changes = porcelain.get_tree_changes(self.repo.path) self.assertEqual(changes["add"][0], filename.encode("ascii")) self.assertEqual(len(changes["add"]), 1) self.assertEqual(len(changes["modify"]), 0) self.assertEqual(len(changes["delete"]), 0) def test_get_tree_changes_modify(self): """Unit test for get_tree_changes modify.""" # Make a dummy file, stage, commit, modify filename = "foo" fullpath = os.path.join(self.repo.path, filename) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) with open(fullpath, "w") as f: f.write("otherstuff") porcelain.add(repo=self.repo.path, paths=fullpath) changes = porcelain.get_tree_changes(self.repo.path) self.assertEqual(changes["modify"][0], filename.encode("ascii")) self.assertEqual(len(changes["add"]), 0) self.assertEqual(len(changes["modify"]), 1) self.assertEqual(len(changes["delete"]), 0) def test_get_tree_changes_delete(self): """Unit test for get_tree_changes delete.""" # Make a dummy file, stage, commit, remove filename = "foo" fullpath = os.path.join(self.repo.path, filename) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) cwd = os.getcwd() try: os.chdir(self.repo.path) porcelain.remove(repo=self.repo.path, paths=[filename]) finally: os.chdir(cwd) changes = porcelain.get_tree_changes(self.repo.path) self.assertEqual(changes["delete"][0], filename.encode("ascii")) self.assertEqual(len(changes["add"]), 0) self.assertEqual(len(changes["modify"]), 0) self.assertEqual(len(changes["delete"]), 1) def test_get_untracked_paths(self): with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("ignored\n") with open(os.path.join(self.repo.path, "ignored"), "w") as f: f.write("blah\n") with open(os.path.join(self.repo.path, "notignored"), "w") as f: f.write("blah\n") os.symlink( os.path.join(self.repo.path, os.pardir, "external_target"), os.path.join(self.repo.path, "link"), ) self.assertEqual( set(["ignored", "notignored", ".gitignore", "link"]), set( porcelain.get_untracked_paths( self.repo.path, self.repo.path, self.repo.open_index() ) ), ) self.assertEqual( set([".gitignore", "notignored", "link"]), set(porcelain.status(self.repo).untracked), ) self.assertEqual( set([".gitignore", "notignored", "ignored", "link"]), set(porcelain.status(self.repo, ignored=True).untracked), ) def test_get_untracked_paths_subrepo(self): with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("nested/\n") with open(os.path.join(self.repo.path, "notignored"), "w") as f: f.write("blah\n") subrepo = Repo.init(os.path.join(self.repo.path, "nested"), mkdir=True) with open(os.path.join(subrepo.path, "ignored"), "w") as f: f.write("bleep\n") with open(os.path.join(subrepo.path, "with"), "w") as f: f.write("bloop\n") with open(os.path.join(subrepo.path, "manager"), "w") as f: f.write("blop\n") self.assertEqual( set([".gitignore", "notignored", os.path.join("nested", "")]), set( porcelain.get_untracked_paths( self.repo.path, self.repo.path, self.repo.open_index() ) ), ) self.assertEqual( set([".gitignore", "notignored"]), set( porcelain.get_untracked_paths( self.repo.path, self.repo.path, self.repo.open_index(), exclude_ignored=True, ) ), ) self.assertEqual( set(["ignored", "with", "manager"]), set( porcelain.get_untracked_paths( subrepo.path, subrepo.path, subrepo.open_index() ) ), ) self.assertEqual( set(), set( porcelain.get_untracked_paths( subrepo.path, self.repo.path, self.repo.open_index(), ) ), ) self.assertEqual( set([os.path.join('nested', 'ignored'), os.path.join('nested', 'with'), os.path.join('nested', 'manager')]), set( porcelain.get_untracked_paths( self.repo.path, subrepo.path, self.repo.open_index(), ) ), ) def test_get_untracked_paths_subdir(self): with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("subdir/\nignored") with open(os.path.join(self.repo.path, "notignored"), "w") as f: f.write("blah\n") os.mkdir(os.path.join(self.repo.path, "subdir")) with open(os.path.join(self.repo.path, "ignored"), "w") as f: f.write("foo") with open(os.path.join(self.repo.path, "subdir", "ignored"), "w") as f: f.write("foo") self.assertEqual( set( [ ".gitignore", "notignored", "ignored", os.path.join("subdir", ""), ] ), set( porcelain.get_untracked_paths( self.repo.path, self.repo.path, self.repo.open_index(), ) ) ) self.assertEqual( set([".gitignore", "notignored"]), set( porcelain.get_untracked_paths( self.repo.path, self.repo.path, self.repo.open_index(), exclude_ignored=True, ) ) ) # TODO(jelmer): Add test for dulwich.porcelain.daemon class UploadPackTests(PorcelainTestCase): """Tests for upload_pack.""" def test_upload_pack(self): outf = BytesIO() exitcode = porcelain.upload_pack(self.repo.path, BytesIO(b"0000"), outf) outlines = outf.getvalue().splitlines() self.assertEqual([b"0000"], outlines) self.assertEqual(0, exitcode) class ReceivePackTests(PorcelainTestCase): """Tests for receive_pack.""" def test_receive_pack(self): filename = "foo" fullpath = os.path.join(self.repo.path, filename) with open(fullpath, "w") as f: f.write("stuff") porcelain.add(repo=self.repo.path, paths=fullpath) self.repo.do_commit( message=b"test status", author=b"author ", committer=b"committer ", author_timestamp=1402354300, commit_timestamp=1402354300, author_timezone=0, commit_timezone=0, ) outf = BytesIO() exitcode = porcelain.receive_pack(self.repo.path, BytesIO(b"0000"), outf) outlines = outf.getvalue().splitlines() self.assertEqual( [ b"0091319b56ce3aee2d489f759736a79cc552c9bb86d9 HEAD\x00 report-status " # noqa: E501 b"delete-refs quiet ofs-delta side-band-64k " b"no-done symref=HEAD:refs/heads/master", b"003f319b56ce3aee2d489f759736a79cc552c9bb86d9 refs/heads/master", b"0000", ], outlines, ) self.assertEqual(0, exitcode) class BranchListTests(PorcelainTestCase): def test_standard(self): self.assertEqual(set([]), set(porcelain.branch_list(self.repo))) def test_new_branch(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.branch_create(self.repo, b"foo") self.assertEqual( set([b"master", b"foo"]), set(porcelain.branch_list(self.repo)) ) class BranchCreateTests(PorcelainTestCase): def test_branch_exists(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.branch_create(self.repo, b"foo") self.assertRaises(porcelain.Error, porcelain.branch_create, self.repo, b"foo") porcelain.branch_create(self.repo, b"foo", force=True) def test_new_branch(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.branch_create(self.repo, b"foo") self.assertEqual( set([b"master", b"foo"]), set(porcelain.branch_list(self.repo)) ) class BranchDeleteTests(PorcelainTestCase): def test_simple(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.branch_create(self.repo, b"foo") self.assertTrue(b"foo" in porcelain.branch_list(self.repo)) porcelain.branch_delete(self.repo, b"foo") self.assertFalse(b"foo" in porcelain.branch_list(self.repo)) def test_simple_unicode(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo[b"HEAD"] = c1.id porcelain.branch_create(self.repo, "foo") self.assertTrue(b"foo" in porcelain.branch_list(self.repo)) porcelain.branch_delete(self.repo, "foo") self.assertFalse(b"foo" in porcelain.branch_list(self.repo)) class FetchTests(PorcelainTestCase): def test_simple(self): outstream = BytesIO() errstream = BytesIO() # create a file for initial commit handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test", author=b"test ", committer=b"test ", ) # Setup target repo target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, target_path) target_repo = porcelain.clone( self.repo.path, target=target_path, errstream=errstream ) # create a second file to be pushed handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test2", author=b"test2 ", committer=b"test2 ", ) self.assertFalse(self.repo[b"HEAD"].id in target_repo) target_repo.close() # Fetch changes into the cloned repo porcelain.fetch(target_path, "origin", outstream=outstream, errstream=errstream) # Assert that fetch updated the local image of the remote self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs()) # Check the target repo for pushed changes with Repo(target_path) as r: self.assertTrue(self.repo[b"HEAD"].id in r) def test_with_remote_name(self): remote_name = "origin" outstream = BytesIO() errstream = BytesIO() # create a file for initial commit handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test", author=b"test ", committer=b"test ", ) # Setup target repo target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, target_path) target_repo = porcelain.clone( self.repo.path, target=target_path, errstream=errstream ) # Capture current refs target_refs = target_repo.get_refs() # create a second file to be pushed handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.commit( repo=self.repo.path, message=b"test2", author=b"test2 ", committer=b"test2 ", ) self.assertFalse(self.repo[b"HEAD"].id in target_repo) target_config = target_repo.get_config() target_config.set( (b"remote", remote_name.encode()), b"url", self.repo.path.encode() ) target_repo.close() # Fetch changes into the cloned repo porcelain.fetch( target_path, remote_name, outstream=outstream, errstream=errstream ) # Assert that fetch updated the local image of the remote self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs()) # Check the target repo for pushed changes, as well as updates # for the refs with Repo(target_path) as r: self.assertTrue(self.repo[b"HEAD"].id in r) self.assertNotEqual(self.repo.get_refs(), target_refs) def assert_correct_remote_refs( self, local_refs, remote_refs, remote_name=b"origin" ): """Assert that known remote refs corresponds to actual remote refs.""" local_ref_prefix = b"refs/heads" remote_ref_prefix = b"refs/remotes/" + remote_name locally_known_remote_refs = { k[len(remote_ref_prefix) + 1 :]: v for k, v in local_refs.items() if k.startswith(remote_ref_prefix) } normalized_remote_refs = { k[len(local_ref_prefix) + 1 :]: v for k, v in remote_refs.items() if k.startswith(local_ref_prefix) } self.assertEqual(locally_known_remote_refs, normalized_remote_refs) class RepackTests(PorcelainTestCase): def test_empty(self): porcelain.repack(self.repo) def test_simple(self): handle, fullpath = tempfile.mkstemp(dir=self.repo.path) os.close(handle) porcelain.add(repo=self.repo.path, paths=fullpath) porcelain.repack(self.repo) class LsTreeTests(PorcelainTestCase): def test_empty(self): porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) f = StringIO() porcelain.ls_tree(self.repo, b"HEAD", outstream=f) self.assertEqual(f.getvalue(), "") def test_simple(self): # Commit a dummy file then modify it fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("origstuff") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) f = StringIO() porcelain.ls_tree(self.repo, b"HEAD", outstream=f) self.assertEqual( f.getvalue(), "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n", ) def test_recursive(self): # Create a directory then write a dummy file in it dirpath = os.path.join(self.repo.path, "adir") filepath = os.path.join(dirpath, "afile") os.mkdir(dirpath) with open(filepath, "w") as f: f.write("origstuff") porcelain.add(repo=self.repo.path, paths=[filepath]) porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) f = StringIO() porcelain.ls_tree(self.repo, b"HEAD", outstream=f) self.assertEqual( f.getvalue(), "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n", ) f = StringIO() porcelain.ls_tree(self.repo, b"HEAD", outstream=f, recursive=True) self.assertEqual( f.getvalue(), "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n" "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tadir" "/afile\n", ) class LsRemoteTests(PorcelainTestCase): def test_empty(self): self.assertEqual({}, porcelain.ls_remote(self.repo.path)) def test_some(self): cid = porcelain.commit( repo=self.repo.path, message=b"test status", author=b"author ", committer=b"committer ", ) self.assertEqual( {b"refs/heads/master": cid, b"HEAD": cid}, porcelain.ls_remote(self.repo.path), ) class LsFilesTests(PorcelainTestCase): def test_empty(self): self.assertEqual([], list(porcelain.ls_files(self.repo))) def test_simple(self): # Commit a dummy file then modify it fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("origstuff") porcelain.add(repo=self.repo.path, paths=[fullpath]) self.assertEqual([b"foo"], list(porcelain.ls_files(self.repo))) class RemoteAddTests(PorcelainTestCase): def test_new(self): porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich") c = self.repo.get_config() self.assertEqual( c.get((b"remote", b"jelmer"), b"url"), b"git://jelmer.uk/code/dulwich", ) def test_exists(self): porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich") self.assertRaises( porcelain.RemoteExists, porcelain.remote_add, self.repo, "jelmer", "git://jelmer.uk/code/dulwich", ) class CheckIgnoreTests(PorcelainTestCase): def test_check_ignored(self): with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("foo") foo_path = os.path.join(self.repo.path, "foo") with open(foo_path, "w") as f: f.write("BAR") bar_path = os.path.join(self.repo.path, "bar") with open(bar_path, "w") as f: f.write("BAR") self.assertEqual(["foo"], list(porcelain.check_ignore(self.repo, [foo_path]))) self.assertEqual([], list(porcelain.check_ignore(self.repo, [bar_path]))) def test_check_added_abs(self): path = os.path.join(self.repo.path, "foo") with open(path, "w") as f: f.write("BAR") self.repo.stage(["foo"]) with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("foo\n") self.assertEqual([], list(porcelain.check_ignore(self.repo, [path]))) self.assertEqual( ["foo"], list(porcelain.check_ignore(self.repo, [path], no_index=True)), ) def test_check_added_rel(self): with open(os.path.join(self.repo.path, "foo"), "w") as f: f.write("BAR") self.repo.stage(["foo"]) with open(os.path.join(self.repo.path, ".gitignore"), "w") as f: f.write("foo\n") cwd = os.getcwd() os.mkdir(os.path.join(self.repo.path, "bar")) os.chdir(os.path.join(self.repo.path, "bar")) try: self.assertEqual(list(porcelain.check_ignore(self.repo, ["../foo"])), []) self.assertEqual( ["../foo"], list(porcelain.check_ignore(self.repo, ["../foo"], no_index=True)), ) finally: os.chdir(cwd) class UpdateHeadTests(PorcelainTestCase): def test_set_to_branch(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo.refs[b"refs/heads/blah"] = c1.id porcelain.update_head(self.repo, "blah") self.assertEqual(c1.id, self.repo.head()) self.assertEqual(b"ref: refs/heads/blah", self.repo.refs.read_ref(b"HEAD")) def test_set_to_branch_detached(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo.refs[b"refs/heads/blah"] = c1.id porcelain.update_head(self.repo, "blah", detached=True) self.assertEqual(c1.id, self.repo.head()) self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD")) def test_set_to_commit_detached(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo.refs[b"refs/heads/blah"] = c1.id porcelain.update_head(self.repo, c1.id, detached=True) self.assertEqual(c1.id, self.repo.head()) self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD")) def test_set_new_branch(self): [c1] = build_commit_graph(self.repo.object_store, [[1]]) self.repo.refs[b"refs/heads/blah"] = c1.id porcelain.update_head(self.repo, "blah", new_branch="bar") self.assertEqual(c1.id, self.repo.head()) self.assertEqual(b"ref: refs/heads/bar", self.repo.refs.read_ref(b"HEAD")) class MailmapTests(PorcelainTestCase): def test_no_mailmap(self): self.assertEqual( b"Jelmer Vernooij ", porcelain.check_mailmap(self.repo, b"Jelmer Vernooij "), ) def test_mailmap_lookup(self): with open(os.path.join(self.repo.path, ".mailmap"), "wb") as f: f.write( b"""\ Jelmer Vernooij """ ) self.assertEqual( b"Jelmer Vernooij ", porcelain.check_mailmap(self.repo, b"Jelmer Vernooij "), ) class FsckTests(PorcelainTestCase): def test_none(self): self.assertEqual([], list(porcelain.fsck(self.repo))) def test_git_dir(self): obj = Tree() a = Blob() a.data = b"foo" obj.add(b".git", 0o100644, a.id) self.repo.object_store.add_objects([(a, None), (obj, None)]) self.assertEqual( [(obj.id, "invalid name .git")], [(sha, str(e)) for (sha, e) in porcelain.fsck(self.repo)], ) class DescribeTests(PorcelainTestCase): def test_no_commits(self): self.assertRaises(KeyError, porcelain.describe, self.repo.path) def test_single_commit(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) sha = porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) self.assertEqual( "g{}".format(sha[:7].decode("ascii")), porcelain.describe(self.repo.path), ) def test_tag(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, ) self.assertEqual("tryme", porcelain.describe(self.repo.path)) def test_tag_and_commit(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) porcelain.tag_create( self.repo.path, b"tryme", b"foo ", b"bar", annotated=True, ) with open(fullpath, "w") as f: f.write("BAR2") porcelain.add(repo=self.repo.path, paths=[fullpath]) sha = porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) self.assertEqual( "tryme-1-g{}".format(sha[:7].decode("ascii")), porcelain.describe(self.repo.path), ) class PathToTreeTests(PorcelainTestCase): def setUp(self): super(PathToTreeTests, self).setUp() self.fp = os.path.join(self.test_dir, "bar") with open(self.fp, "w") as f: f.write("something") oldcwd = os.getcwd() self.addCleanup(os.chdir, oldcwd) os.chdir(self.test_dir) def test_path_to_tree_path_base(self): self.assertEqual(b"bar", porcelain.path_to_tree_path(self.test_dir, self.fp)) self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar")) self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "bar")) cwd = os.getcwd() self.assertEqual( b"bar", porcelain.path_to_tree_path(".", os.path.join(cwd, "bar")) ) self.assertEqual(b"bar", porcelain.path_to_tree_path(cwd, "bar")) def test_path_to_tree_path_syntax(self): self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar")) def test_path_to_tree_path_error(self): with self.assertRaises(ValueError): with tempfile.TemporaryDirectory() as od: porcelain.path_to_tree_path(od, self.fp) def test_path_to_tree_path_rel(self): cwd = os.getcwd() os.mkdir(os.path.join(self.repo.path, "foo")) os.mkdir(os.path.join(self.repo.path, "foo/bar")) try: os.chdir(os.path.join(self.repo.path, "foo/bar")) with open("baz", "w") as f: f.write("contents") self.assertEqual(b"bar/baz", porcelain.path_to_tree_path("..", "baz")) self.assertEqual( b"bar/baz", porcelain.path_to_tree_path( os.path.join(os.getcwd(), ".."), os.path.join(os.getcwd(), "baz"), ), ) self.assertEqual( b"bar/baz", porcelain.path_to_tree_path("..", os.path.join(os.getcwd(), "baz")), ) self.assertEqual( b"bar/baz", porcelain.path_to_tree_path(os.path.join(os.getcwd(), ".."), "baz"), ) finally: os.chdir(cwd) class GetObjectByPathTests(PorcelainTestCase): def test_simple(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", ) self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data) self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data) def test_encoding(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) porcelain.commit( self.repo.path, message=b"Some message", author=b"Joe ", committer=b"Bob ", encoding=b"utf-8", ) self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data) self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data) def test_missing(self): self.assertRaises(KeyError, porcelain.get_object_by_path, self.repo, "foo") class WriteTreeTests(PorcelainTestCase): def test_simple(self): fullpath = os.path.join(self.repo.path, "foo") with open(fullpath, "w") as f: f.write("BAR") porcelain.add(repo=self.repo.path, paths=[fullpath]) self.assertEqual( b"d2092c8a9f311f0311083bf8d177f2ca0ab5b241", porcelain.write_tree(self.repo), ) class ActiveBranchTests(PorcelainTestCase): def test_simple(self): self.assertEqual(b"master", porcelain.active_branch(self.repo))