gitplier
The gitplier library
A Python library for easy and performant querying and parsing of Git repositories.
- When used correctly, it's fast. See the example below.
- Understands specific types of repositories (stable kernel, RHEL kernel) and offers specialized methods for them.
- Has no Python dependencies. At run time, it requires the
gitbinary in the system. - Unlike naive attempts to execute
git, gitplier runs correctly even with exotic git configurations.
Documentation
Documentation and API reference for the latest version.
Goals
The intention of this library is to become a kitchen sink of useful functions. Still, the functions should be as generic as possible. When making a function more generic requires more verbosity at the function call sites, we opt for more verbosity.
We aim for backward compatible API. Making the development of the library easier is never an excuse for breaking users.
The API
The good starting point for exploring gitplier is the class gitplier.Repository.
Anything starting with an underscore does not constitute API and should not be used. Always
import the gitplier module and use the symbols from it. E. g., use only
gitplier.GitError, despite internally it being gitplier._exceptions.GitError.
Do not depend on the GitError message content. It's meant for human consumption, not for
programmatic parsing.
Example
Calculate how many per cent of stable commits were backported to CentOS Stream 9. This completes in about 6 seconds on a moderate machine.
import gitplier
import os
# Open the kernel stable repository.
stable = gitplier.kernel.KernelStableRepository(os.path.expanduser('~/git/stable'))
# Create a set that will hold all upstream commits referenced from the stable commits.
in_stable = set()
# Examine all stable branches from v5.14 on.
for branch_name, upstream_tag in stable.stable_branches(since='v5.14'):
in_stable.update(
commit.upstream
for commit in stable.log(upstream_tag, branch_name, fields=(), include_merges=False)
# Walk all commits in the branch. Ignore merges. To speed up the walking, do not
# fetch any additional field.
)
# Open the CentOS Stream 9 repository.
cs9 = gitplier.kernel.KernelRHELRepository(os.path.expanduser('~/git/centos-stream-9'))
# Count the upstream references in the CS9 tree that are also backported to stable.
count = sum(
len(set(commit.upstream).intersection(in_stable))
for commit in cs9.log('v5.14', 'main', fields=(), include_merges=False)
)
print(f'{count / len(in_stable) * 100:.1f}%')
Why not pygit2?
All git operations in gitplier are performed by executing the external git command. The
disadvantages of that are clear but the advantages outweight them:
No compatibility problems. libgit2 (and thus pygit2) does not support new git features and sometimes exhibits compatibility bugs.
Easier to use API. Where pygit2 has a specific Oid type, gitplier has just a string. Etc.
No dependency problems. While pygit2 and libgit2 do better job than most libraries out there, they do not have a stable API. You need to use the right versions when working with them.
Performance. As surprising as it is, libgit2 is so slow that doing tons of fork/exec calls is significantly faster for most operations.
1""" 2.. include:: ../README.md 3""" 4from ._base import GitObject 5from ._commit import Commit 6from ._config import Config 7from ._diff import Change 8from ._exceptions import GitError, GitExecError, MultipleValuesError 9from ._file import FilesystemObject, Directory, File 10from ._remote import Remote 11from ._repository import Repository 12from ._status import (Status, StatusChanged, StatusMoved, StatusRenamed, StatusCopied, 13 StatusConflict, StatusUntracked) 14from ._tag import Tag 15from . import kernel 16 17__all__ = [ 18 'GitObject', 19 'Commit', 20 'Config', 21 'Change', 22 'GitError', 'GitExecError', 'MultipleValuesError', 23 'FilesystemObject', 'Directory', 'File', 24 'Remote', 25 'Repository', 26 'Status', 'StatusChanged', 'StatusMoved', 'StatusRenamed', 'StatusCopied', 27 'StatusConflict', 'StatusUntracked', 28 'Tag', 29 'kernel', 30]
2class GitObject: 3 """An abstract class representing a Git object. The only attribute you can rely on to be 4 present is `sha`.""" 5 sha: str
An abstract class representing a Git object. The only attribute you can rely on to be
present is sha.
14class Commit(GitObject): 15 """A single commit. It will always have the `sha` attribute populated. It may have also 16 other attributes, depending on how it was obtained.""" 17 sha: str 18 tree: str 19 parents: list[str] 20 author_name: str 21 author_email: str 22 author_date: datetime.datetime 23 committer_name: str 24 committer_email: str 25 committer_date: datetime.datetime 26 refs: str 27 subject: str 28 body: str 29 raw_body: str 30 notes: str 31 patch: str 32 changes: list[Change] 33 34 @classmethod 35 def _construct(cls, fields: Sequence[str], iterator: _Peekable) -> Commit: 36 """An internal method to construct the commit from a list of fields and an iterator 37 yielding the fields in order.""" 38 sentinel = next(iterator) 39 if sentinel != _SENTINEL: 40 raise GitError(f'Unexpected output from git: "{sentinel}"') 41 result = cls() 42 for field in fields: 43 if field == 'changes': 44 result.changes = list(iter(functools.partial(Change._construct, iterator), 45 None)) 46 elif field == 'patch': 47 result._set_patch(iterator) 48 else: 49 result._set_field(field, next(iterator)) 50 assert hasattr(result, 'sha') 51 return result 52 53 def _set_patch(self, iterator: _Peekable) -> None: 54 """An internal method to populate the `patch` attribute.""" 55 if iterator.peek_match(lambda val: val == ''): 56 # If there is both --raw and --patch, git inserts an empty record between the 57 # whatchanged output and the patch. Consume it. 58 next(iterator) 59 if iterator.peek_match(lambda val: val.startswith('diff') or val.startswith('\ndiff')): 60 # If there's only --patch (without --raw), git inserts a newline before the patch. 61 # Delete it. 62 patch = next(iterator).removeprefix('\n') 63 while True: 64 # Patch is not zero terminated. Detect the sentinel at the start of the next 65 # commit. 66 if patch.endswith(f'\n{_SENTINEL}'): 67 patch = patch.removesuffix(_SENTINEL) 68 iterator.pushback(_SENTINEL) 69 break 70 # No sentinel. This is either the last record, or there's a null byte in the 71 # patch. 72 try: 73 cont = next(iterator) 74 except StopIteration: 75 # It is the last record. 76 break 77 # There was a null byte in the patch. Continue reading. 78 patch = f'{patch}\0{cont}' 79 self.patch = patch 80 else: 81 # no patch present 82 self.patch = '' 83 84 def _set_field(self, field: str, value: str) -> None: 85 """An internal method to populate an attribute. Can be overriden in subclasses for 86 specialized field handling.""" 87 converted: Any = value 88 if field == 'parents': 89 converted = value.split(' ') 90 elif field.endswith('_date'): 91 converted = email.utils.parsedate_to_datetime(value) 92 setattr(self, field, converted) 93 94 def __str__(self) -> str: 95 if hasattr(self, 'subject'): 96 return f'{self.sha} ("{self.subject}")' 97 else: 98 return self.sha
A single commit. It will always have the sha attribute populated. It may have also
other attributes, depending on how it was obtained.
9class Config: 10 """A git config. It's always tied to a particular repository. Supports both setting and 11 getting of values using the array syntax ([]). The key is a tuple of strings, forming 12 a path to the value. The same key can be present multiple times in the config and you must 13 take that into account when working with config. The array access operation returns the 14 last value in the config (or raises KeyError). The array assignment operation raises 15 MultipleValuesError when the key is present in the config multiple times. There are 16 specialized methods available for working with multi value keys. 17 18 Note the config is cached after the first access; subsequent external changes to the 19 config will not be visible. Internal config changes are correctly accounted for, either by 20 modifying the config or by invalidating the cache.""" 21 22 def __init__(self, repository: Repository) -> None: 23 """@private 24 Never create instances directly; access them via Repository.config.""" 25 self._repository = repository 26 self._data: Optional[list[tuple[str, str]]] = None 27 28 def _get_data(self) -> list[tuple[str, str]]: 29 # Unfortunatelly, we cannot parse the keys here. The output from git config list is 30 # ambiguous, sinnce '.' is a permitted part of a subsection name. This can happen for 31 # example for remotes with '.' in the remote name. Meaning we cannot split the key to 32 # individual parts. Ideally, we would store the result in a trie for fast lookups. But 33 # configs tend to be short and it doesn't justify bringing in a 3rd party dependency. 34 if self._data is None: 35 self._data = [] 36 for entry in self._repository._run_git_records('config', ('list', '-z')): 37 k, v = entry.split('\n', 1) 38 self._data.append((k, v)) 39 return self._data 40 41 def refresh(self) -> None: 42 """Invalidates the config cache. A subsequent config access will reload the config.""" 43 self._data = None 44 45 def get_all(self, *args: str) -> list[str]: 46 """Lookup the given key in the config, returning all values found.""" 47 real_key = '.'.join(args) 48 return [v for k, v in self._get_data() if k == real_key] 49 50 def __getitem__(self, key: Iterable[str]) -> str: 51 args = tuple(key) 52 result = self.get_all(*args) 53 if not result: 54 raise KeyError(f'Config key {".".join(args)} not present') 55 return result[-1] 56 57 def __setitem__(self, key: Iterable[str], value: str) -> None: 58 real_key = '.'.join(key) 59 real_value = str(value) 60 data = self._get_data() 61 indexes = [i for i, (k, v) in enumerate(data) if k == real_key] 62 if len(indexes) > 1: 63 raise MultipleValuesError(f'Multiple values for key {real_key}') 64 self._repository._run_git('config', ('set', '--local', real_key, real_value)) 65 if not indexes: 66 data.append((real_key, real_value)) 67 else: 68 data[indexes[0]] = (real_key, real_value)
A git config. It's always tied to a particular repository. Supports both setting and getting of values using the array syntax ([]). The key is a tuple of strings, forming a path to the value. The same key can be present multiple times in the config and you must take that into account when working with config. The array access operation returns the last value in the config (or raises KeyError). The array assignment operation raises MultipleValuesError when the key is present in the config multiple times. There are specialized methods available for working with multi value keys.
Note the config is cached after the first access; subsequent external changes to the config will not be visible. Internal config changes are correctly accounted for, either by modifying the config or by invalidating the cache.
41 def refresh(self) -> None: 42 """Invalidates the config cache. A subsequent config access will reload the config.""" 43 self._data = None
Invalidates the config cache. A subsequent config access will reload the config.
45 def get_all(self, *args: str) -> list[str]: 46 """Lookup the given key in the config, returning all values found.""" 47 real_key = '.'.join(args) 48 return [v for k, v in self._get_data() if k == real_key]
Lookup the given key in the config, returning all values found.
7class Change: 8 """Information about a single changed file. This is parsed information from git 9 whatchanged. Request the `changes` field to get the list of changes. The attributes are: 10 `src_path`, `dst_path`, `src_mode`, `dst_mode`, `status` and `score`.""" 11 def __init__(self, src_path: str, dst_path: str, src_mode: str, dst_mode: str, 12 status: str, score: Optional[int]) -> None: 13 """@private 14 Never create instances directly; access them via Commit.changes or 15 Repository.get_changes().""" 16 self.src_path = src_path 17 self.dst_path = dst_path 18 self.src_mode = src_mode 19 self.dst_mode = dst_mode 20 self.status = status 21 self.score = score 22 23 @classmethod 24 def _construct(cls, iterator: _Peekable) -> Optional[Change]: 25 """An internal method to construct the change from an iterator of git output. Returns 26 None if there's no more change in the output.""" 27 if not iterator.peek_match(lambda val: val.startswith(':') or val.startswith('\n:')): 28 return None 29 flags = next(iterator).lstrip('\n:').split(' ') 30 score = None 31 status = flags[4] 32 if len(status) > 1: 33 score = int(status[1:]) 34 status = status[0] 35 src_path = dst_path = next(iterator) 36 if status in ('C', 'R'): 37 dst_path = next(iterator) 38 return Change(src_path, dst_path, flags[0], flags[1], status, score) 39 40 def __str__(self) -> str: 41 score = '' if self.score is None else f'({self.score}%)' 42 path = (self.src_path if self.src_path == self.dst_path 43 else f'{self.src_path} -> {self.dst_path}') 44 return f'{self.status}{score} {path}'
5class GitError(Exception): 6 """An exception raised when something goes wrong while running the git operation.""" 7 pass
An exception raised when something goes wrong while running the git operation.
10class GitExecError(GitError): 11 """An error executing git. Contains two attributes: `command` is a list with the command 12 and arguments. `stderr` is the standard error output or None if the stderr was consumed 13 by the progress callback.""" 14 def __init__(self, command: list[str], stderr: Optional[Union[str, bytes, bytearray]] = None): 15 """@private 16 Never create instances directly; they are only created by the library.""" 17 super().__init__('Failed to run git.') 18 self.command = command 19 if isinstance(stderr, (bytes, bytearray)): 20 stderr_str: Optional[str] = stderr.decode('utf-8', errors='replace') 21 else: 22 stderr_str = stderr 23 self.stderr = stderr_str 24 25 def __str__(self) -> str: 26 msg = super().__str__() 27 cmd = ' '.join(self.command) 28 err = '' 29 if self.stderr: 30 nl = self.stderr.find('\n') 31 if nl < 0 or nl == len(self.stderr) - 1: 32 # If there's only one line on stderr, add it to the message. 33 err = f' and the error was: "{self.stderr.rstrip()}"' 34 return f'{msg} The command was: "{cmd}"{err}'
37class MultipleValuesError(GitError): 38 """Multiple values for a key encountered in an operation that supports only a single 39 value.""" 40 pass
Multiple values for a key encountered in an operation that supports only a single value.
10class FilesystemObject: 11 """A base class for file info. The only attribute you can rely on to be present is 12 `path` and `sha`. The `path` is always relative to the repository root. 13 14 Since v1.2.""" 15 path: str 16 sha: str 17 18 def __init__(self, repository: 'Repository', path: str, sha: str) -> None: 19 """@private 20 Never create instances directly; access them via `Repository.ls`, `Repository.root` 21 or `Directory.ls`.""" 22 self.path = path 23 self.sha = sha 24 self._repository = repository 25 26 @classmethod 27 def _construct(cls, repository: 'Repository', iterator: Iterator[str]) -> FilesystemObject: 28 """An internal method to construct an appropriate subclass from an iterator.""" 29 data = next(iterator) 30 fields = data.split(' ', 4) 31 if len(fields) != 5: 32 raise GitError(f'Unexpected output from git: "{data}"') 33 if fields[0] == 'tree': 34 return Directory(repository, fields[4], fields[1]) 35 if fields[0] == 'blob': 36 try: 37 mode = int(fields[2]) 38 size = int(fields[3]) 39 except ValueError: 40 raise GitError(f'Unexpected output from git: "{data}"') 41 return File(repository, fields[4], fields[1], mode, size) 42 raise GitError(f'Unexpected output from git: "{data}"')
45class Directory(FilesystemObject): 46 """A directory. The available attributes are `path` and `sha`. The root directory has 47 an empty `path`. 48 49 Since v1.2.""" 50 51 def ls(self) -> Iterator[FilesystemObject]: 52 """Yield items in this directory. It's not recursive.""" 53 for obj in self._repository.ls(self.sha, recursive=False): 54 obj.path = os.path.join(self.path, obj.path) 55 yield obj
A directory. The available attributes are path and sha. The root directory has
an empty path.
Since v1.2.
58class File(FilesystemObject): 59 """A non-directory. A file, a symlink, etc. The current version of gitplier does not 60 distinguish those further but future versions might, as subclasses of `File`. To remain 61 compatible, always use `isinstance(o, File)` instead of `type(o) == File`. The available 62 attributes are `path`, `sha`, `mode` and `size`. 63 64 Since v1.2.""" 65 mode: int 66 size: int 67 68 def __init__(self, repository: 'Repository', path: str, sha: str, mode: int, 69 size: int) -> None: 70 """@private 71 Never create instances directly; access them via `Repository.ls` or `Directory.ls`.""" 72 super().__init__(repository, path, sha) 73 self.mode = mode 74 self.size = size 75 76 def get(self) -> bytes: 77 """Return the content of this file.""" 78 return self._repository._get_blob(self.sha)
A non-directory. A file, a symlink, etc. The current version of gitplier does not
distinguish those further but future versions might, as subclasses of File. To remain
compatible, always use isinstance(o, File) instead of type(o) == File. The available
attributes are path, sha, mode and size.
Since v1.2.
8class Remote: 9 """A single remote of the git repository. Contains `name`, `urls`, `push_urls` and 10 `refspecs` attributes.""" 11 def __init__(self, repository: Repository, name: str, urls: list[str], 12 push_urls: list[str], refspecs: list[str]) -> None: 13 """@private 14 Never create instances directly; access them via Repository.remotes.""" 15 self._repository = repository 16 self.name = name 17 self.urls = urls 18 self.push_urls = urls 19 self.refspecs = refspecs 20 21 def _update_first_url(self, target: list[str], new_url: str) -> None: 22 self._repository.config.refresh() 23 if not target: 24 target.append(new_url) 25 else: 26 target[0] = new_url 27 28 def rename(self, new_name: str) -> None: 29 """Renames the remote.""" 30 self._repository._run_git('remote', ('rename', self.name, new_name)) 31 self._repository.config.refresh() 32 self.name = new_name 33 34 def set_url(self, new_url: str) -> None: 35 """Sets the first url of the remote to new_url.""" 36 self._repository._run_git('remote', ('set-url', self.name, new_url)) 37 self._update_first_url(self.urls, new_url) 38 39 def set_push_url(self, new_url: str) -> None: 40 """Sets the first push_url of the remote to new_url.""" 41 self._repository._run_git('remote', ('set-url', '--push', self.name, new_url)) 42 self._update_first_url(self.push_urls, new_url) 43 44 def fetch(self, *, progress: Optional[Callable[[str], None]] = None) -> None: 45 """Fetch the remote. If `progress` is given, it will be called with the progress of 46 the fetching.""" 47 self._repository.fetch(self.name, progress=progress)
28 def rename(self, new_name: str) -> None: 29 """Renames the remote.""" 30 self._repository._run_git('remote', ('rename', self.name, new_name)) 31 self._repository.config.refresh() 32 self.name = new_name
Renames the remote.
34 def set_url(self, new_url: str) -> None: 35 """Sets the first url of the remote to new_url.""" 36 self._repository._run_git('remote', ('set-url', self.name, new_url)) 37 self._update_first_url(self.urls, new_url)
Sets the first url of the remote to new_url.
39 def set_push_url(self, new_url: str) -> None: 40 """Sets the first push_url of the remote to new_url.""" 41 self._repository._run_git('remote', ('set-url', '--push', self.name, new_url)) 42 self._update_first_url(self.push_urls, new_url)
Sets the first push_url of the remote to new_url.
44 def fetch(self, *, progress: Optional[Callable[[str], None]] = None) -> None: 45 """Fetch the remote. If `progress` is given, it will be called with the progress of 46 the fetching.""" 47 self._repository.fetch(self.name, progress=progress)
Fetch the remote. If progress is given, it will be called with the progress of
the fetching.
57class Repository: 58 """A generic git repository. The parameter is a file system path.""" 59 _COMMIT_TYPE: type[Commit] = Commit 60 _FORCED_LOG_FIELDS: tuple[str, ...] = ('sha',) 61 config: Config 62 """The repository git config.""" 63 64 def __init__(self, path: str) -> None: 65 """Opens the repository at the given path. Raises GitError if the path is not in a git 66 repository.""" 67 try: 68 self.path = os.path.realpath(path) 69 except OSError: 70 raise GitError(f'Path {path} does not exist.') from None 71 self.config = Config(self) 72 try: 73 self._git_dir = self._run_git('rev-parse', ('--absolute-git-dir',)) 74 except GitError: 75 raise GitError(f'No git repository in {path}') from None 76 77 def _run_git_raw(self, command: str, args: Iterable[str] = (), 78 handled_errors: Iterable[int] = ()) -> bytes: 79 return _execute._run_git_raw(self.path, command, args, handled_errors=handled_errors) 80 81 def _run_git(self, command: str, args: Iterable[str] = (), 82 handled_errors: Iterable[int] = (), 83 progress: Optional[Callable[[str], None]] = None) -> str: 84 if not progress: 85 return _execute._run_git(self.path, command, args, handled_errors=handled_errors) 86 return _execute._run_git_progress(self.path, command, args, progress, 87 handled_errors=handled_errors) 88 89 def _run_git_records(self, command: str, args: Iterable[str] = (), 90 separator: str = '\0') -> Iterator[str]: 91 return _execute._run_git_records(self.path, command, args, separator) 92 93 def _log(self, rev_range: str, fields: Iterable[str], 94 args: list[str]) -> tuple[list[str], _Peekable]: 95 """An internal only function to construct log related commands. Returns a tuple with 96 the list of fields and an iterator over a running git.""" 97 if isinstance(fields, str): 98 # common error: forgotten list (tuple) when requesting a single fields only 99 raise ValueError('fields must be an iterable, not a single string') 100 patch = False 101 changes = False 102 full_fields_set: set[str] = set() 103 full_fields_set.update(self._FORCED_LOG_FIELDS) 104 full_fields_set.update(fields) 105 if 'patch' in full_fields_set: 106 full_fields_set.remove('patch') 107 patch = True 108 if 'changes' in full_fields_set: 109 full_fields_set.remove('changes') 110 changes = True 111 full_fields = list(full_fields_set) 112 try: 113 formatted_fields = '%x00'.join(f'%{_COMMIT_FIELD_MAP[f]}' for f in full_fields) 114 except KeyError as e: 115 raise ValueError(f"Unknown field: '{e.args[0]}'") from None 116 full_args = ['-z', f'--pretty=tformat:{_SENTINEL}%x00{formatted_fields}'] 117 # changes (--raw) and patch (--patch) must come last, in this order 118 if changes: 119 full_fields.append('changes') 120 full_args.append('--raw') 121 if patch: 122 full_fields.append('patch') 123 full_args.append('--patch') 124 full_args.extend(args) 125 full_args.append(rev_range) 126 return full_fields, _Peekable(self._run_git_records('log', full_args)) 127 128 def _get_blob(self, sha: str) -> bytes: 129 return self._run_git_raw("cat-file", ("blob", sha)) 130 131 @classmethod 132 def create(cls, path: str, *, bare: bool = False) -> Repository: 133 """Create an empty repository in the given path. If `bare` is True, create a bare 134 repository. Raises GitError if the repository cannot be created. If a repository 135 at the given path already exists, the result is undefined.""" 136 args = [] 137 if bare: 138 args.append('--bare') 139 _execute._run_git(path, 'init', args) 140 return cls(path) 141 142 def resolve(self, id: str, type: str = 'object') -> Optional[str]: 143 """Resolve the given `id` to the full sha. The `id` can be a sha, an abbreviated 144 sha, a branch name, a tag, etc. You can further restrict the possible type of the 145 returned object using `type`, e.g. `type='commit'`. Note this can be also used to 146 resolve a tag to the underlying commit; just pass the tag with `type='commit'`. 147 Returns the full sha or None.""" 148 args = ['--verify', '--quiet', '--end-of-options', f'{id}^{{{type}}}'] 149 try: 150 return self._run_git('rev-parse', args).rstrip('\n') 151 except GitError: 152 return None 153 154 def get_commit(self, id: str, 155 fields: Iterable[str] = COMMIT_DEFAULT_FIELDS) -> Optional[Commit]: 156 """Returns the commit with the given `id` or None if such commit does not exist. 157 The `id` can be any commit identifier: a sha, an abbreviated sha, a branch, etc. 158 The `fields` is an iterable of fields to fetch and include in the result. In general, 159 less fields means faster execution. Note however that some fields you did not request 160 may still be returned. Available fields are: 'sha', 'tree', 'parents', 'author_name', 161 'author_email', 'author_date', 'committer_name', 'committer_email', 'committer_date', 162 'refs', 'subject', 'body', 'raw_body', 'notes', 'patch', 'changes'. Most of the 163 values are strings but the '*_date' fields are `datetime.datetime`, 'parents' is 164 a list of strings and 'changes' is a list of `Change`.""" 165 full_fields, it = self._log(id, fields, ['-1']) 166 try: 167 return self._COMMIT_TYPE._construct(full_fields, it) 168 except (StopIteration, GitError): 169 return None 170 171 def log(self, start: Optional[str], end: Optional[str], 172 fields: Iterable[str] = COMMIT_DEFAULT_FIELDS, *, 173 files: Optional[Iterable[str]] = None, 174 include_merges: bool = True, 175 topological: bool = False, 176 reverse: bool = False) -> Iterator[Commit]: 177 """Yields a Commit instance for every commit in the history from `start` to `end`. 178 `fields` is a list of fields to include in the result; see `get_commit` for 179 information on `fields`. `files` can contain a list of file patterns to limit the log 180 to. If `include_merges` is True (the default), merge commits are included in the 181 result. If `topological` is True, the commits are ordered in the topological order, 182 i.e. commits on different lines of history are not intermixed and parents are returned 183 only after all of their children. If `reverse` is True, the commits are returned from 184 the oldest to the newest. 185 186 Note: it's generally faster to not specify `files` but add `'changes'` to `fields` 187 and do your own filtering of commits in Python.""" 188 args = [] 189 if not include_merges: 190 args.append('--no-merges') 191 if topological: 192 args.append('--topo-order') 193 if reverse: 194 args.append('--reverse') 195 if files: 196 args.append('--') 197 args.extend(files) 198 if start is None: 199 if end is None: 200 raise GitError('A start, end or both must be given') 201 rev_range = end 202 elif end is None: 203 rev_range = f'{start}..' 204 else: 205 rev_range = f'{start}..{end}' 206 full_fields, it = self._log(rev_range, fields, args) 207 try: 208 while True: 209 yield self._COMMIT_TYPE._construct(full_fields, it) 210 except StopIteration: 211 return 212 213 def get_changes(self, start: str, end: Optional[str]) -> Iterator[Change]: 214 """Yields a Change instance for every file changed between `start` and `end`. Note 215 that this works on a diff between `start` and `end`; if there was a change to a file 216 that was later reverted, such file will not be returned (unless it had more 217 changes). 218 219 Since v1.3.""" 220 if end is None: 221 rev_range = f'{start}..' 222 else: 223 rev_range = f'{start}..{end}' 224 args = ('--no-patch', '--raw', '-z', rev_range) 225 it = _Peekable(self._run_git_records('diff', args)) 226 yield from iter(functools.partial(Change._construct, it), None) 227 228 def get_file(self, path: str, id: Optional[str] = None) -> bytes: 229 """Return the given file from the given point in history. `path` must be a relative 230 path to the repository root. `id` is a commit-ish indicating the point in history. If 231 `id` is None, the current HEAD is used.""" 232 args = (f'HEAD:{path}' if id is None else f'{id}:{path}',) 233 return self._run_git_raw('show', args) 234 235 def ls(self, id: Optional[str] = None, *, path: Optional[str] = None, 236 recursive: bool = True) -> Iterator[FilesystemObject]: 237 """Yield info about all files in the repository. If `id` is specified, get the files 238 from this point in history; it is expected to be a commit-ish. If `path` is specified 239 and does not end with `'/'`, get only this file. If `path` ends with `/`, get the 240 files under this directory. By default, the tree is walked recursively. Pass 241 `recursive = False` to disable that. 242 243 Since v1.2.""" 244 args = ['--format=%(objecttype) %(objectname) %(objectmode) %(objectsize) %(path)', 245 '-z'] 246 if recursive: 247 args.append('-r') 248 args.append('HEAD' if id is None else id) 249 if path is not None: 250 args.append(path) 251 it = self._run_git_records('ls-tree', args) 252 try: 253 while True: 254 yield FilesystemObject._construct(self, it) 255 except StopIteration: 256 return 257 258 def root(self, id: Optional[str] = None) -> Directory: 259 """Get the info about the root directory of this repository. Compared to 260 `Repository.ls()`, this allows more controlled way of browsing the repository. Get the 261 root directory by `root = Repository.root()`, call `root.ls()` and similarly 262 call `ls` on the yielded directory entries, as appropriate. 263 If `id` is specified, get the root from this point in history; it is expected to be 264 a commit-ish. 265 266 Since v1.2.""" 267 if id is None: 268 id = 'HEAD' 269 sha = self.resolve(id, 'tree') 270 if sha is None: 271 raise GitError(f'Cannot resolve {id} to a tree') 272 return Directory(self, '', sha) 273 274 def branch_names(self, *, local: bool = True, remote: bool = False, 275 patterns: Optional[Iterable[str]] = None) -> list[str]: 276 """Returns a list of branch names, optionally matching a list of `patterns`. 277 `local` and `remote` determine whether local or remote branches should be listed, 278 respectively.""" 279 args = [] 280 if local: 281 if remote: 282 args.append('--all') 283 elif remote: 284 args.append('--remotes') 285 else: 286 # local is False and remote is False; no branches 287 return [] 288 args.append('--list') 289 if patterns: 290 args.extend(patterns) 291 return [s.lstrip().split(' ')[0] for s in self._run_git('branch', args).splitlines()] 292 293 def tags(self, *, patterns: Optional[Iterable[str]] = None, 294 merged_to: Optional[str] = None) -> Iterator[Tag]: 295 """Yields a Tag instance for each tag, optionally matching a list of `patterns`. If 296 `merged_to` is passed, return only the tags that are present in the history of (merged 297 to) the given commit.""" 298 args = [] 299 if merged_to: 300 args.append('--merged') 301 args.append(merged_to) 302 fields = list(_TAG_FIELD_MAP.keys()) 303 args.append('--format') 304 args.append(''.join(f'%00%({_TAG_FIELD_MAP[f]})' for f in fields) + '%00') 305 args.append('refs/tags/*') 306 it = self._run_git_records('for-each-ref', args) 307 try: 308 # consume the first empty field 309 next(it) 310 while True: 311 tag = Tag._construct(fields, it) 312 if patterns and not any(fnmatch.fnmatchcase(tag.name, pat) 313 for pat in patterns): 314 continue 315 yield tag 316 except StopIteration: 317 return 318 319 def remotes(self) -> list[Remote]: 320 """Returns a list of all remotes. This may cache the config; see `gitplier.Config` 321 for details.""" 322 result = [] 323 for name in _splitlines(self._run_git('remote')): 324 remote = self.get_remote(name) 325 if remote is None: 326 raise GitError('Remote {name} does not have a URL configured') 327 result.append(remote) 328 return result 329 330 def get_remote(self, name: str) -> Optional[Remote]: 331 """Gets the remote with the given name. This may cache the config; see 332 `gitplier.Config` for details.""" 333 urls = self.config.get_all('remote', name, 'url') 334 if not urls: 335 return None 336 push_urls = self.config.get_all('remote', name, 'pushurl') 337 refspecs = self.config.get_all('remote', name, 'fetch') 338 return Remote(self, name, urls, push_urls, refspecs) 339 340 def add_remote(self, name: str, url: str) -> Remote: 341 """Add a remote.""" 342 self._run_git('remote', ('add', name, url)) 343 self.config.refresh() 344 return Remote(self, name, [url], [], [f'+refs/heads/*:refs/remotes/{name}/*']) 345 346 def fetch(self, remote_name: Optional[str] = None, *, 347 progress: Optional[Callable[[str], None]] = None) -> None: 348 """Fetch the given remote. If `remote_name` is not specified, fetch the upstream 349 remote for the current branch, or 'origin'. If `progress` is given, it will be called 350 with the progress of the fetching.""" 351 args = [] 352 if progress: 353 args.append('--progress') 354 else: 355 args.append('--quiet') 356 if remote_name: 357 args.append(remote_name) 358 self._run_git('fetch', args, progress=progress) 359 360 def describe(self, id: str, *, long: bool = False) -> Optional[str]: 361 """Describe the given `id`. The `id` can be a sha, an abbreviated sha, a branch name, 362 a tag, etc. If `long` is True, the result will be always in the long format, that is, 363 tag-number-sha, even if the number is zero. Returns the full sha or None.""" 364 args = [] 365 if long: 366 args.append('--long') 367 args.append('--end-of-options') 368 args.append(id) 369 try: 370 return self._run_git('describe', args).rstrip('\n') 371 except GitError: 372 return None 373 374 def merge_base(self, /, id1: str, id2: str) -> Optional[str]: 375 """Find the best common ancestor of the given commits. Returns the full sha of the 376 common ancestor or None if there's no such ancestor. Raises GitError if the ids cannot 377 be resolved or when there's an error running git.""" 378 args = ('--end-of-options', id1, id2) 379 try: 380 return self._run_git('merge-base', args, handled_errors=(1,)).rstrip('\n') 381 except _HandledError: 382 return None 383 384 def status(self, *, untracked: bool = False) -> list[Status]: 385 """Get the working tree status. Returns a list containing status of modified files 386 as subclasses of `gitplier.Status`. Untracked files are not included in the list, 387 unless `untracked` argument is True.""" 388 args = ('--porcelain=v2', '-z', '--renames', '-unormal' if untracked else '-uno') 389 it = self._run_git_records('status', args) 390 result = [] 391 try: 392 while True: 393 result.append(Status._construct(it)) 394 except StopIteration: 395 pass 396 return result 397 398 def checkout(self, id: Optional[str] = None, *, detach: bool = False, 399 progress: Optional[Callable[[str], None]] = None) -> None: 400 """Check out the given commit-ish `id`. If the `id` is a branch name, that branch will 401 be checked out (and later commits will advance it). However, if you pass `detach` 402 = True, the tree will have a detached HEAD (pointing to `id`) instead. If `progress` 403 is given, it will be called with the progress of the checkout.""" 404 args = ['--no-guess', '--progress' if progress else '--quiet'] 405 if id: 406 args.append(id) 407 self._run_git('checkout', args, progress=progress) 408 409 def set_sparse_checkout(self, directories: Optional[Iterable[str]] = None, 410 progress: Optional[Callable[[str], None]] = None) -> None: 411 """Sets the tree to use sparse checkouts. `directories` is the list of directories to 412 include in the checkout; note that all files along each path are included in the 413 checkout, not just leaf files. As a special case, the files in repository root are 414 always included, even with `directories` = None. Repeated calls to 415 `set_sparse_checkout` will correctly update the list of sparse directories. This call 416 will also take care of adjusting the files in the tree, with one exception: if the 417 tree was never checked out (e.g. this is used after a call to `add_worktree(checkout 418 = False)`), it will stay that way. Call `checkout()` without parameters in such case. 419 Depending on what you pass in `directories`, this call may need to checkout a lot of 420 files; pass `progress` if you want to get the progress of the call.""" 421 args = ['set', '--no-sparse-index', '--cone'] 422 if directories is not None: 423 args.extend(directories) 424 self._run_git('sparse-checkout', args, progress=progress) 425 426 def disable_sparse_checkout(self, 427 progress: Optional[Callable[[str], None]] = None) -> None: 428 """Restore the tree to include all files. Pass `progress` if you want to get the 429 progress of the call.""" 430 self._run_git('sparse-checkout', ('disable',), progress=progress) 431 432 def add_worktree(self, path: str, id: str, *, detach: bool = False, 433 checkout: bool = True) -> Repository: 434 """Creates a new worktree in the given `path` pointing to the commit-ish `id`. If the 435 `id` is a branch name, that branch will be checked out in the new worktree; this 436 branch must not be checked out out in any other worktree. However, if you pass 437 `detach` = True, the worktree will have a detached HEAD (pointing to `id`) instead. If 438 `checkout` is False, do not check out the tree in the new worktree. Returns 439 a `Repository` pointing to the newly created worktree.""" 440 # Currently, if `id` does not exist but a remote branch with that name exists, 441 # "git worktree add" creates a tracking branch. There's no documented way to switch 442 # off that behavior. We want to be on the safe side here to allow backward compatible 443 # behavior of future versions of the library. Check the existence ourselves. 444 if not self.resolve(id): 445 raise GitError(f'Commit-ish {id} does not exist') 446 args = ['add', '--checkout' if checkout else '--no-checkout'] 447 if detach: 448 args.append('--detach') 449 args.append(path) 450 args.append(id) 451 self._run_git('worktree', args) 452 self.config.refresh() 453 return type(self)(path) 454 455 def remove_worktree(self, path: Optional[str] = None, *, force: bool = False) -> None: 456 """Remove the current worktree. The main worktree cannot be removed. If the current 457 worktree is not clean, this will fail unless `force` is True. Alternatively, specify 458 `path` to remove the given worktree instead of the current one.""" 459 args = ['remove'] 460 if force: 461 args.append('--force') 462 args.append(self.path if path is None else path) 463 self._run_git('worktree', args)
A generic git repository. The parameter is a file system path.
64 def __init__(self, path: str) -> None: 65 """Opens the repository at the given path. Raises GitError if the path is not in a git 66 repository.""" 67 try: 68 self.path = os.path.realpath(path) 69 except OSError: 70 raise GitError(f'Path {path} does not exist.') from None 71 self.config = Config(self) 72 try: 73 self._git_dir = self._run_git('rev-parse', ('--absolute-git-dir',)) 74 except GitError: 75 raise GitError(f'No git repository in {path}') from None
Opens the repository at the given path. Raises GitError if the path is not in a git repository.
131 @classmethod 132 def create(cls, path: str, *, bare: bool = False) -> Repository: 133 """Create an empty repository in the given path. If `bare` is True, create a bare 134 repository. Raises GitError if the repository cannot be created. If a repository 135 at the given path already exists, the result is undefined.""" 136 args = [] 137 if bare: 138 args.append('--bare') 139 _execute._run_git(path, 'init', args) 140 return cls(path)
Create an empty repository in the given path. If bare is True, create a bare
repository. Raises GitError if the repository cannot be created. If a repository
at the given path already exists, the result is undefined.
142 def resolve(self, id: str, type: str = 'object') -> Optional[str]: 143 """Resolve the given `id` to the full sha. The `id` can be a sha, an abbreviated 144 sha, a branch name, a tag, etc. You can further restrict the possible type of the 145 returned object using `type`, e.g. `type='commit'`. Note this can be also used to 146 resolve a tag to the underlying commit; just pass the tag with `type='commit'`. 147 Returns the full sha or None.""" 148 args = ['--verify', '--quiet', '--end-of-options', f'{id}^{{{type}}}'] 149 try: 150 return self._run_git('rev-parse', args).rstrip('\n') 151 except GitError: 152 return None
Resolve the given id to the full sha. The id can be a sha, an abbreviated
sha, a branch name, a tag, etc. You can further restrict the possible type of the
returned object using type, e.g. type='commit'. Note this can be also used to
resolve a tag to the underlying commit; just pass the tag with type='commit'.
Returns the full sha or None.
154 def get_commit(self, id: str, 155 fields: Iterable[str] = COMMIT_DEFAULT_FIELDS) -> Optional[Commit]: 156 """Returns the commit with the given `id` or None if such commit does not exist. 157 The `id` can be any commit identifier: a sha, an abbreviated sha, a branch, etc. 158 The `fields` is an iterable of fields to fetch and include in the result. In general, 159 less fields means faster execution. Note however that some fields you did not request 160 may still be returned. Available fields are: 'sha', 'tree', 'parents', 'author_name', 161 'author_email', 'author_date', 'committer_name', 'committer_email', 'committer_date', 162 'refs', 'subject', 'body', 'raw_body', 'notes', 'patch', 'changes'. Most of the 163 values are strings but the '*_date' fields are `datetime.datetime`, 'parents' is 164 a list of strings and 'changes' is a list of `Change`.""" 165 full_fields, it = self._log(id, fields, ['-1']) 166 try: 167 return self._COMMIT_TYPE._construct(full_fields, it) 168 except (StopIteration, GitError): 169 return None
Returns the commit with the given id or None if such commit does not exist.
The id can be any commit identifier: a sha, an abbreviated sha, a branch, etc.
The fields is an iterable of fields to fetch and include in the result. In general,
less fields means faster execution. Note however that some fields you did not request
may still be returned. Available fields are: 'sha', 'tree', 'parents', 'author_name',
'author_email', 'author_date', 'committer_name', 'committer_email', 'committer_date',
'refs', 'subject', 'body', 'raw_body', 'notes', 'patch', 'changes'. Most of the
values are strings but the '*_date' fields are datetime.datetime, 'parents' is
a list of strings and 'changes' is a list of Change.
171 def log(self, start: Optional[str], end: Optional[str], 172 fields: Iterable[str] = COMMIT_DEFAULT_FIELDS, *, 173 files: Optional[Iterable[str]] = None, 174 include_merges: bool = True, 175 topological: bool = False, 176 reverse: bool = False) -> Iterator[Commit]: 177 """Yields a Commit instance for every commit in the history from `start` to `end`. 178 `fields` is a list of fields to include in the result; see `get_commit` for 179 information on `fields`. `files` can contain a list of file patterns to limit the log 180 to. If `include_merges` is True (the default), merge commits are included in the 181 result. If `topological` is True, the commits are ordered in the topological order, 182 i.e. commits on different lines of history are not intermixed and parents are returned 183 only after all of their children. If `reverse` is True, the commits are returned from 184 the oldest to the newest. 185 186 Note: it's generally faster to not specify `files` but add `'changes'` to `fields` 187 and do your own filtering of commits in Python.""" 188 args = [] 189 if not include_merges: 190 args.append('--no-merges') 191 if topological: 192 args.append('--topo-order') 193 if reverse: 194 args.append('--reverse') 195 if files: 196 args.append('--') 197 args.extend(files) 198 if start is None: 199 if end is None: 200 raise GitError('A start, end or both must be given') 201 rev_range = end 202 elif end is None: 203 rev_range = f'{start}..' 204 else: 205 rev_range = f'{start}..{end}' 206 full_fields, it = self._log(rev_range, fields, args) 207 try: 208 while True: 209 yield self._COMMIT_TYPE._construct(full_fields, it) 210 except StopIteration: 211 return
Yields a Commit instance for every commit in the history from start to end.
fields is a list of fields to include in the result; see get_commit for
information on fields. files can contain a list of file patterns to limit the log
to. If include_merges is True (the default), merge commits are included in the
result. If topological is True, the commits are ordered in the topological order,
i.e. commits on different lines of history are not intermixed and parents are returned
only after all of their children. If reverse is True, the commits are returned from
the oldest to the newest.
Note: it's generally faster to not specify files but add 'changes' to fields
and do your own filtering of commits in Python.
213 def get_changes(self, start: str, end: Optional[str]) -> Iterator[Change]: 214 """Yields a Change instance for every file changed between `start` and `end`. Note 215 that this works on a diff between `start` and `end`; if there was a change to a file 216 that was later reverted, such file will not be returned (unless it had more 217 changes). 218 219 Since v1.3.""" 220 if end is None: 221 rev_range = f'{start}..' 222 else: 223 rev_range = f'{start}..{end}' 224 args = ('--no-patch', '--raw', '-z', rev_range) 225 it = _Peekable(self._run_git_records('diff', args)) 226 yield from iter(functools.partial(Change._construct, it), None)
Yields a Change instance for every file changed between start and end. Note
that this works on a diff between start and end; if there was a change to a file
that was later reverted, such file will not be returned (unless it had more
changes).
Since v1.3.
228 def get_file(self, path: str, id: Optional[str] = None) -> bytes: 229 """Return the given file from the given point in history. `path` must be a relative 230 path to the repository root. `id` is a commit-ish indicating the point in history. If 231 `id` is None, the current HEAD is used.""" 232 args = (f'HEAD:{path}' if id is None else f'{id}:{path}',) 233 return self._run_git_raw('show', args)
Return the given file from the given point in history. path must be a relative
path to the repository root. id is a commit-ish indicating the point in history. If
id is None, the current HEAD is used.
235 def ls(self, id: Optional[str] = None, *, path: Optional[str] = None, 236 recursive: bool = True) -> Iterator[FilesystemObject]: 237 """Yield info about all files in the repository. If `id` is specified, get the files 238 from this point in history; it is expected to be a commit-ish. If `path` is specified 239 and does not end with `'/'`, get only this file. If `path` ends with `/`, get the 240 files under this directory. By default, the tree is walked recursively. Pass 241 `recursive = False` to disable that. 242 243 Since v1.2.""" 244 args = ['--format=%(objecttype) %(objectname) %(objectmode) %(objectsize) %(path)', 245 '-z'] 246 if recursive: 247 args.append('-r') 248 args.append('HEAD' if id is None else id) 249 if path is not None: 250 args.append(path) 251 it = self._run_git_records('ls-tree', args) 252 try: 253 while True: 254 yield FilesystemObject._construct(self, it) 255 except StopIteration: 256 return
Yield info about all files in the repository. If id is specified, get the files
from this point in history; it is expected to be a commit-ish. If path is specified
and does not end with '/', get only this file. If path ends with /, get the
files under this directory. By default, the tree is walked recursively. Pass
recursive = False to disable that.
Since v1.2.
258 def root(self, id: Optional[str] = None) -> Directory: 259 """Get the info about the root directory of this repository. Compared to 260 `Repository.ls()`, this allows more controlled way of browsing the repository. Get the 261 root directory by `root = Repository.root()`, call `root.ls()` and similarly 262 call `ls` on the yielded directory entries, as appropriate. 263 If `id` is specified, get the root from this point in history; it is expected to be 264 a commit-ish. 265 266 Since v1.2.""" 267 if id is None: 268 id = 'HEAD' 269 sha = self.resolve(id, 'tree') 270 if sha is None: 271 raise GitError(f'Cannot resolve {id} to a tree') 272 return Directory(self, '', sha)
Get the info about the root directory of this repository. Compared to
Repository.ls(), this allows more controlled way of browsing the repository. Get the
root directory by root = Repository.root(), call root.ls() and similarly
call ls on the yielded directory entries, as appropriate.
If id is specified, get the root from this point in history; it is expected to be
a commit-ish.
Since v1.2.
274 def branch_names(self, *, local: bool = True, remote: bool = False, 275 patterns: Optional[Iterable[str]] = None) -> list[str]: 276 """Returns a list of branch names, optionally matching a list of `patterns`. 277 `local` and `remote` determine whether local or remote branches should be listed, 278 respectively.""" 279 args = [] 280 if local: 281 if remote: 282 args.append('--all') 283 elif remote: 284 args.append('--remotes') 285 else: 286 # local is False and remote is False; no branches 287 return [] 288 args.append('--list') 289 if patterns: 290 args.extend(patterns) 291 return [s.lstrip().split(' ')[0] for s in self._run_git('branch', args).splitlines()]
Returns a list of branch names, optionally matching a list of patterns.
local and remote determine whether local or remote branches should be listed,
respectively.
319 def remotes(self) -> list[Remote]: 320 """Returns a list of all remotes. This may cache the config; see `gitplier.Config` 321 for details.""" 322 result = [] 323 for name in _splitlines(self._run_git('remote')): 324 remote = self.get_remote(name) 325 if remote is None: 326 raise GitError('Remote {name} does not have a URL configured') 327 result.append(remote) 328 return result
Returns a list of all remotes. This may cache the config; see gitplier.Config
for details.
330 def get_remote(self, name: str) -> Optional[Remote]: 331 """Gets the remote with the given name. This may cache the config; see 332 `gitplier.Config` for details.""" 333 urls = self.config.get_all('remote', name, 'url') 334 if not urls: 335 return None 336 push_urls = self.config.get_all('remote', name, 'pushurl') 337 refspecs = self.config.get_all('remote', name, 'fetch') 338 return Remote(self, name, urls, push_urls, refspecs)
Gets the remote with the given name. This may cache the config; see
gitplier.Config for details.
340 def add_remote(self, name: str, url: str) -> Remote: 341 """Add a remote.""" 342 self._run_git('remote', ('add', name, url)) 343 self.config.refresh() 344 return Remote(self, name, [url], [], [f'+refs/heads/*:refs/remotes/{name}/*'])
Add a remote.
346 def fetch(self, remote_name: Optional[str] = None, *, 347 progress: Optional[Callable[[str], None]] = None) -> None: 348 """Fetch the given remote. If `remote_name` is not specified, fetch the upstream 349 remote for the current branch, or 'origin'. If `progress` is given, it will be called 350 with the progress of the fetching.""" 351 args = [] 352 if progress: 353 args.append('--progress') 354 else: 355 args.append('--quiet') 356 if remote_name: 357 args.append(remote_name) 358 self._run_git('fetch', args, progress=progress)
Fetch the given remote. If remote_name is not specified, fetch the upstream
remote for the current branch, or 'origin'. If progress is given, it will be called
with the progress of the fetching.
360 def describe(self, id: str, *, long: bool = False) -> Optional[str]: 361 """Describe the given `id`. The `id` can be a sha, an abbreviated sha, a branch name, 362 a tag, etc. If `long` is True, the result will be always in the long format, that is, 363 tag-number-sha, even if the number is zero. Returns the full sha or None.""" 364 args = [] 365 if long: 366 args.append('--long') 367 args.append('--end-of-options') 368 args.append(id) 369 try: 370 return self._run_git('describe', args).rstrip('\n') 371 except GitError: 372 return None
Describe the given id. The id can be a sha, an abbreviated sha, a branch name,
a tag, etc. If long is True, the result will be always in the long format, that is,
tag-number-sha, even if the number is zero. Returns the full sha or None.
374 def merge_base(self, /, id1: str, id2: str) -> Optional[str]: 375 """Find the best common ancestor of the given commits. Returns the full sha of the 376 common ancestor or None if there's no such ancestor. Raises GitError if the ids cannot 377 be resolved or when there's an error running git.""" 378 args = ('--end-of-options', id1, id2) 379 try: 380 return self._run_git('merge-base', args, handled_errors=(1,)).rstrip('\n') 381 except _HandledError: 382 return None
Find the best common ancestor of the given commits. Returns the full sha of the common ancestor or None if there's no such ancestor. Raises GitError if the ids cannot be resolved or when there's an error running git.
384 def status(self, *, untracked: bool = False) -> list[Status]: 385 """Get the working tree status. Returns a list containing status of modified files 386 as subclasses of `gitplier.Status`. Untracked files are not included in the list, 387 unless `untracked` argument is True.""" 388 args = ('--porcelain=v2', '-z', '--renames', '-unormal' if untracked else '-uno') 389 it = self._run_git_records('status', args) 390 result = [] 391 try: 392 while True: 393 result.append(Status._construct(it)) 394 except StopIteration: 395 pass 396 return result
Get the working tree status. Returns a list containing status of modified files
as subclasses of gitplier.Status. Untracked files are not included in the list,
unless untracked argument is True.
398 def checkout(self, id: Optional[str] = None, *, detach: bool = False, 399 progress: Optional[Callable[[str], None]] = None) -> None: 400 """Check out the given commit-ish `id`. If the `id` is a branch name, that branch will 401 be checked out (and later commits will advance it). However, if you pass `detach` 402 = True, the tree will have a detached HEAD (pointing to `id`) instead. If `progress` 403 is given, it will be called with the progress of the checkout.""" 404 args = ['--no-guess', '--progress' if progress else '--quiet'] 405 if id: 406 args.append(id) 407 self._run_git('checkout', args, progress=progress)
Check out the given commit-ish id. If the id is a branch name, that branch will
be checked out (and later commits will advance it). However, if you pass detach
= True, the tree will have a detached HEAD (pointing to id) instead. If progress
is given, it will be called with the progress of the checkout.
409 def set_sparse_checkout(self, directories: Optional[Iterable[str]] = None, 410 progress: Optional[Callable[[str], None]] = None) -> None: 411 """Sets the tree to use sparse checkouts. `directories` is the list of directories to 412 include in the checkout; note that all files along each path are included in the 413 checkout, not just leaf files. As a special case, the files in repository root are 414 always included, even with `directories` = None. Repeated calls to 415 `set_sparse_checkout` will correctly update the list of sparse directories. This call 416 will also take care of adjusting the files in the tree, with one exception: if the 417 tree was never checked out (e.g. this is used after a call to `add_worktree(checkout 418 = False)`), it will stay that way. Call `checkout()` without parameters in such case. 419 Depending on what you pass in `directories`, this call may need to checkout a lot of 420 files; pass `progress` if you want to get the progress of the call.""" 421 args = ['set', '--no-sparse-index', '--cone'] 422 if directories is not None: 423 args.extend(directories) 424 self._run_git('sparse-checkout', args, progress=progress)
Sets the tree to use sparse checkouts. directories is the list of directories to
include in the checkout; note that all files along each path are included in the
checkout, not just leaf files. As a special case, the files in repository root are
always included, even with directories = None. Repeated calls to
set_sparse_checkout will correctly update the list of sparse directories. This call
will also take care of adjusting the files in the tree, with one exception: if the
tree was never checked out (e.g. this is used after a call to add_worktree(checkout
= False)), it will stay that way. Call checkout() without parameters in such case.
Depending on what you pass in directories, this call may need to checkout a lot of
files; pass progress if you want to get the progress of the call.
426 def disable_sparse_checkout(self, 427 progress: Optional[Callable[[str], None]] = None) -> None: 428 """Restore the tree to include all files. Pass `progress` if you want to get the 429 progress of the call.""" 430 self._run_git('sparse-checkout', ('disable',), progress=progress)
Restore the tree to include all files. Pass progress if you want to get the
progress of the call.
432 def add_worktree(self, path: str, id: str, *, detach: bool = False, 433 checkout: bool = True) -> Repository: 434 """Creates a new worktree in the given `path` pointing to the commit-ish `id`. If the 435 `id` is a branch name, that branch will be checked out in the new worktree; this 436 branch must not be checked out out in any other worktree. However, if you pass 437 `detach` = True, the worktree will have a detached HEAD (pointing to `id`) instead. If 438 `checkout` is False, do not check out the tree in the new worktree. Returns 439 a `Repository` pointing to the newly created worktree.""" 440 # Currently, if `id` does not exist but a remote branch with that name exists, 441 # "git worktree add" creates a tracking branch. There's no documented way to switch 442 # off that behavior. We want to be on the safe side here to allow backward compatible 443 # behavior of future versions of the library. Check the existence ourselves. 444 if not self.resolve(id): 445 raise GitError(f'Commit-ish {id} does not exist') 446 args = ['add', '--checkout' if checkout else '--no-checkout'] 447 if detach: 448 args.append('--detach') 449 args.append(path) 450 args.append(id) 451 self._run_git('worktree', args) 452 self.config.refresh() 453 return type(self)(path)
Creates a new worktree in the given path pointing to the commit-ish id. If the
id is a branch name, that branch will be checked out in the new worktree; this
branch must not be checked out out in any other worktree. However, if you pass
detach = True, the worktree will have a detached HEAD (pointing to id) instead. If
checkout is False, do not check out the tree in the new worktree. Returns
a Repository pointing to the newly created worktree.
455 def remove_worktree(self, path: Optional[str] = None, *, force: bool = False) -> None: 456 """Remove the current worktree. The main worktree cannot be removed. If the current 457 worktree is not clean, this will fail unless `force` is True. Alternatively, specify 458 `path` to remove the given worktree instead of the current one.""" 459 args = ['remove'] 460 if force: 461 args.append('--force') 462 args.append(self.path if path is None else path) 463 self._run_git('worktree', args)
Remove the current worktree. The main worktree cannot be removed. If the current
worktree is not clean, this will fail unless force is True. Alternatively, specify
path to remove the given worktree instead of the current one.
19class Status: 20 """A base class for file status. The only attribute you can rely on to be present is 21 `path`.""" 22 path: str 23 24 def __init__(self, path: str) -> None: 25 """@private 26 Never create instances directly; access them via `Repository.status`.""" 27 self.path = path 28 29 @classmethod 30 def _construct(cls, iterator: Iterator[str]) -> Status: 31 """An internal method to construct an appropriate subclass from an iterator.""" 32 data = next(iterator) 33 while data.startswith('# '): 34 # An extended header. We haven't requested any extended headers but the git 35 # documentation says to ignore headers we don't recognize. Better assume 36 # headers can appear with future git versions. 37 data = next(iterator) 38 if data.startswith('1 '): 39 return StatusChanged._construct_changed(data[2:]) 40 if data.startswith('2 '): 41 try: 42 orig_path = next(iterator) 43 except StopIteration: 44 raise GitError('Unexpected end of git output') 45 return StatusMoved._construct_moved(data[2:], orig_path) 46 if data.startswith('u '): 47 return StatusConflict._construct_conflict(data[2:]) 48 if data.startswith('? '): 49 return StatusUntracked(data[2:]) 50 # There's also '! ' for ignored items but we don't implement this currently. 51 raise GitError(f'Unexpected output from git: "{data}"')
A base class for file status. The only attribute you can rely on to be present is
path.
54class StatusChanged(Status): 55 """A changed file. The available attributes are `status_index`, `status_worktree` (each is 56 one of 'modified', 'type changed', 'added', 'deleted', 'renamed', 'copied' or None for 57 unmodified), `mode_head`, `mode_index`, `mode_worktree` (those are integers containing the 58 file mode), `sha_head`, `sha_index` and `path`.""" 59 60 def __init__(self, status_index: Optional[str], status_worktree: Optional[str], 61 mode_head: int, mode_index: int, mode_worktree: int, 62 sha_head: str, sha_index: str, path: str) -> None: 63 """@private 64 Never create instances directly; access them via `Repository.status`.""" 65 self.status_index = status_index 66 self.status_worktree = status_worktree 67 self.mode_head = mode_head 68 self.mode_index = mode_index 69 self.mode_worktree = mode_worktree 70 self.sha_head = sha_head 71 self.sha_index = sha_index 72 self.path = path 73 74 @classmethod 75 def _construct_changed(cls, data: str) -> StatusChanged: 76 try: 77 (xy, sub, mode_head, mode_index, mode_worktree, sha_head, sha_index, 78 path) = data.split(' ', 8) 79 except ValueError: 80 raise GitError('Git returned a wrong number of fields for a changed file') 81 return StatusChanged(_xy_mapping[xy[0]], _xy_mapping[xy[1]], 82 int(mode_head, 8), int(mode_index, 8), int(mode_worktree, 8), 83 sha_head, sha_index, path) 84 # We're ignoring the submodule info (sub) currently.
A changed file. The available attributes are status_index, status_worktree (each is
one of 'modified', 'type changed', 'added', 'deleted', 'renamed', 'copied' or None for
unmodified), mode_head, mode_index, mode_worktree (those are integers containing the
file mode), sha_head, sha_index and path.
87class StatusMoved(StatusChanged): 88 """A renamed or copied file. In addition to the attributes in `StatusChanged` also 89 contains `score` and `orig_path`. This is an abstract base type for `StatusRenamed` and 90 `StatusCopied` and is never returned directly.""" 91 92 def __init__(self, status_index: Optional[str], status_worktree: Optional[str], 93 mode_head: int, mode_index: int, mode_worktree: int, 94 sha_head: str, sha_index: str, score: int, path: str, 95 orig_path: str) -> None: 96 """@private 97 Never create instances directly; access them via `Repository.status`.""" 98 super().__init__(status_index, status_worktree, mode_head, mode_index, mode_worktree, 99 sha_head, sha_index, path) 100 self.score = score 101 self.orig_path = orig_path 102 103 @classmethod 104 def _construct_moved(cls, data: str, orig_path: str) -> StatusMoved: 105 try: 106 (xy, sub, mode_head, mode_index, mode_worktree, sha_head, sha_index, 107 score, path) = data.split(' ', 9) 108 except ValueError: 109 raise GitError('Git returned a wrong number of fields for a renamed/copied file') 110 if score.startswith('R'): 111 result_cls: Type[StatusMoved] = StatusRenamed 112 elif score.startswith('C'): 113 result_cls = StatusCopied 114 else: 115 raise GitError(f'Unknown score type: {score}') 116 return result_cls(_xy_mapping[xy[0]], _xy_mapping[xy[1]], 117 int(mode_head, 8), int(mode_index, 8), int(mode_worktree, 8), 118 sha_head, sha_index, int(score[1:]), path, orig_path)
A renamed or copied file. In addition to the attributes in StatusChanged also
contains score and orig_path. This is an abstract base type for StatusRenamed and
StatusCopied and is never returned directly.
A renamed file.
A copied file.
131class StatusConflict(Status): 132 """A file with an umerged conflict. The available attributes are `status_ours`, 133 `status_theirs` (each is one of 'added', 'deleted', 'updated'), `mode_base`, `mode_ours`, 134 `mode_theirs` (an integer representing the file mode of the common ancestor, HEAD and 135 MERGE_HEAD, respectively), `mode_worktree`, `sha_base`, `sha_ours`, `sha_theirs` and 136 `path`.""" 137 138 def __init__(self, status_ours: str, status_theirs: str, 139 mode_base: int, mode_ours: int, mode_theirs: int, mode_worktree: int, 140 sha_base: str, sha_ours: str, sha_theirs: str, path: str) -> None: 141 """@private 142 Never create instances directly; access them via `Repository.status`.""" 143 self.status_ours = status_ours 144 self.status_theirs = status_theirs 145 self.mode_base = mode_base 146 self.mode_ours = mode_ours 147 self.mode_theirs = mode_theirs 148 self.mode_worktree = mode_worktree 149 self.sha_base = sha_base 150 self.sha_ours = sha_ours 151 self.sha_theirs = sha_theirs 152 self.path = path 153 154 @classmethod 155 def _construct_conflict(cls, data: str) -> StatusConflict: 156 try: 157 (xy, sub, mode_base, mode_ours, mode_theirs, mode_worktree, sha_base, sha_ours, 158 sha_theirs, path) = data.split(' ', 10) 159 except ValueError: 160 raise GitError('Git returned a wrong number of fields for an unmerged file') 161 status_ours = _xy_mapping[xy[0]] 162 status_theirs = _xy_mapping[xy[1]] 163 if status_ours is None or status_theirs is None: 164 raise GitError(f'Git returned unexpected status for a conflict: {xy}') 165 return StatusConflict(status_ours, status_theirs, 166 int(mode_base, 8), int(mode_ours, 8), int(mode_theirs, 8), 167 int(mode_worktree, 8), sha_base, sha_ours, sha_theirs, 168 path)
A file with an umerged conflict. The available attributes are status_ours,
status_theirs (each is one of 'added', 'deleted', 'updated'), mode_base, mode_ours,
mode_theirs (an integer representing the file mode of the common ancestor, HEAD and
MERGE_HEAD, respectively), mode_worktree, sha_base, sha_ours, sha_theirs and
path.
171class StatusUntracked(Status): 172 """An untracked file. The only available attribute is `path`.""" 173 pass
An untracked file. The only available attribute is path.
10class Tag(GitObject): 11 """A single tag. Available attributes are `sha`, `target`, `name`, 'annotated', 12 `creator_name`, `creator_email` and `creator_date`.""" 13 target: str 14 name: str 15 annotated: bool 16 creator_name: str 17 creator_email: str 18 creator_date: datetime.datetime 19 20 @classmethod 21 def _construct(cls, fields: Sequence[str], iterator: Iterator[str]) -> Tag: 22 """An internal method to construct the commit from a list of fields and an iterator 23 yielding the fields in order.""" 24 result = cls() 25 for field in fields: 26 result._set_field(field, next(iterator)) 27 sep = next(iterator) 28 if sep != '\n': 29 # git for-each-ref always adds a terminating newline 30 raise GitError(f'Unexpected output from git: "{sep}"') 31 assert hasattr(result, 'sha') 32 assert hasattr(result, 'name') 33 if not result.target: 34 # Unannotated tags do not have a target, since they're just a ref to a commit. 35 # Set the target ourselves. 36 result.target = result.sha 37 return result 38 39 def _set_field(self, field: str, value: str) -> None: 40 """An internal method to populate an attribute. Can be overriden in subclasses for 41 specialized field handling.""" 42 converted: Any = value 43 if field.endswith('_date') and value: 44 converted = email.utils.parsedate_to_datetime(value) 45 if field == 'name': 46 # strip the 'refs/tags/' 47 converted = value[10:] 48 elif field == 'annotated': 49 converted = value == 'tag' 50 elif field.startswith('committer_'): 51 # unannotated tag 52 if not value: 53 return 54 field = 'creator_' + field[10:] 55 elif field.startswith('tagger_'): 56 # annotated tag 57 if not value: 58 return 59 field = 'creator_' + field[7:] 60 setattr(self, field, converted) 61 62 def __str__(self) -> str: 63 return self.name
A single tag. Available attributes are sha, target, name, 'annotated',
creator_name, creator_email and creator_date.