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 git binary 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:

  1. No compatibility problems. libgit2 (and thus pygit2) does not support new git features and sometimes exhibits compatibility bugs.

  2. Easier to use API. Where pygit2 has a specific Oid type, gitplier has just a string. Etc.

  3. 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.

  4. 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]
class GitObject:
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.

sha: str
class Commit(gitplier.GitObject):
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.

sha: str
tree: str
parents: list[str]
author_name: str
author_email: str
author_date: datetime.datetime
committer_name: str
committer_email: str
committer_date: datetime.datetime
refs: str
subject: str
body: str
raw_body: str
notes: str
patch: str
changes: list[Change]
class Config:
 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.

def refresh(self) -> None:
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.

def get_all(self, *args: str) -> list[str]:
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.

class Change:
 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}'

Information about a single changed file. This is parsed information from git whatchanged. Request the changes field to get the list of changes. The attributes are: src_path, dst_path, src_mode, dst_mode, status and score.

src_path
dst_path
src_mode
dst_mode
status
score
class GitError(builtins.Exception):
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.

class GitExecError(gitplier.GitError):
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}'

An error executing git. Contains two attributes: command is a list with the command and arguments. stderr is the standard error output or None if the stderr was consumed by the progress callback.

command
stderr
class MultipleValuesError(gitplier.GitError):
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.

class FilesystemObject:
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}"')

A base class for file info. The only attribute you can rely on to be present is path and sha. The path is always relative to the repository root.

Since v1.2.

path: str
sha: str
class Directory(gitplier.FilesystemObject):
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.

def ls(self) -> Iterator[FilesystemObject]:
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

Yield items in this directory. It's not recursive.

class File(gitplier.FilesystemObject):
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.

mode: int
size: int
def get(self) -> bytes:
76    def get(self) -> bytes:
77        """Return the content of this file."""
78        return self._repository._get_blob(self.sha)

Return the content of this file.

class Remote:
 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)

A single remote of the git repository. Contains name, urls, push_urls and refspecs attributes.

name
urls
push_urls
refspecs
def rename(self, new_name: str) -> None:
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.

def set_url(self, new_url: str) -> None:
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.

def set_push_url(self, new_url: str) -> None:
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.

def fetch(self, *, progress: Optional[Callable[[str], NoneType]] = None) -> None:
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.

class Repository:
 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.

Repository(path: str)
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.

config: Config

The repository git config.

@classmethod
def create(cls, path: str, *, bare: bool = False) -> 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.

def resolve(self, id: str, type: str = 'object') -> Optional[str]:
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.

def get_commit( self, id: str, fields: Iterable[str] = ['sha', 'tree', 'parents', 'author_name', 'author_email', 'author_date', 'committer_name', 'committer_email', 'committer_date', 'subject', 'body']) -> Optional[Commit]:
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.

def log( self, start: Optional[str], end: Optional[str], fields: Iterable[str] = ['sha', 'tree', 'parents', 'author_name', 'author_email', 'author_date', 'committer_name', 'committer_email', 'committer_date', 'subject', 'body'], *, files: Optional[Iterable[str]] = None, include_merges: bool = True, topological: bool = False, reverse: bool = False) -> Iterator[Commit]:
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.

def get_changes(self, start: str, end: Optional[str]) -> Iterator[Change]:
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.

def get_file(self, path: str, id: Optional[str] = None) -> bytes:
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.

def ls( self, id: Optional[str] = None, *, path: Optional[str] = None, recursive: bool = True) -> Iterator[FilesystemObject]:
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.

def root(self, id: Optional[str] = None) -> Directory:
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.

def branch_names( self, *, local: bool = True, remote: bool = False, patterns: Optional[Iterable[str]] = None) -> list[str]:
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.

def tags( self, *, patterns: Optional[Iterable[str]] = None, merged_to: Optional[str] = None) -> Iterator[Tag]:
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

Yields a Tag instance for each tag, optionally matching a list of patterns. If merged_to is passed, return only the tags that are present in the history of (merged to) the given commit.

def remotes(self) -> list[Remote]:
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.

def get_remote(self, name: str) -> Optional[Remote]:
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.

def add_remote(self, name: str, url: str) -> Remote:
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.

def fetch( self, remote_name: Optional[str] = None, *, progress: Optional[Callable[[str], NoneType]] = None) -> None:
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.

def describe(self, id: str, *, long: bool = False) -> Optional[str]:
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.

def merge_base(self, /, id1: str, id2: str) -> Optional[str]:
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.

def status(self, *, untracked: bool = False) -> list[Status]:
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.

def checkout( self, id: Optional[str] = None, *, detach: bool = False, progress: Optional[Callable[[str], NoneType]] = None) -> None:
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.

def set_sparse_checkout( self, directories: Optional[Iterable[str]] = None, progress: Optional[Callable[[str], NoneType]] = None) -> None:
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.

def disable_sparse_checkout(self, progress: Optional[Callable[[str], NoneType]] = None) -> None:
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.

def add_worktree( self, path: str, id: str, *, detach: bool = False, checkout: bool = True) -> Repository:
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.

def remove_worktree(self, path: Optional[str] = None, *, force: bool = False) -> None:
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.

class Status:
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.

path: str
class StatusChanged(gitplier.Status):
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.

status_index
status_worktree
mode_head
mode_index
mode_worktree
sha_head
sha_index
path
class StatusMoved(gitplier.StatusChanged):
 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.

score
orig_path
class StatusRenamed(gitplier.StatusMoved):
121class StatusRenamed(StatusMoved):
122    """A renamed file."""
123    pass

A renamed file.

class StatusCopied(gitplier.StatusMoved):
126class StatusCopied(StatusMoved):
127    """A copied file."""
128    pass

A copied file.

class StatusConflict(gitplier.Status):
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.

status_ours
status_theirs
mode_base
mode_ours
mode_theirs
mode_worktree
sha_base
sha_ours
sha_theirs
path
class StatusUntracked(gitplier.Status):
171class StatusUntracked(Status):
172    """An untracked file. The only available attribute is `path`."""
173    pass

An untracked file. The only available attribute is path.

class Tag(gitplier.GitObject):
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.

target: str
name: str
annotated: bool
creator_name: str
creator_email: str
creator_date: datetime.datetime