Coverage for yuio / git.py: 97%
434 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9This module provides basic functionality to interact with git.
10It comes in handy when writing deployment scripts.
13Interacting with a repository
14-----------------------------
16All repository interactions are done through the :class:`Repo` class
17and its methods. If an interaction fails, a :class:`GitError` is raised.
19.. autoclass:: Repo
20 :members:
22.. autoclass:: GitError
24.. autoclass:: GitExecError
26.. autoclass:: GitUnavailableError
28.. autoclass:: NotARepositoryError
31Status objects
32--------------
34:meth:`Repo.status` returns repository status parsed from :flag:`git status` command.
35It can show changed and unmerged files and submodules. See details about change
36representation in `git status`__ manual.
38__ https://git-scm.com/docs/git-status#_output
40Yuio represents :flag:`git status` output as close to the original as possible,
41but makes some convenience renames. This results in somewhat unexpected
42class structure:
44.. raw:: html
46 <p>
47 <pre class="mermaid">
48 ---
49 config:
50 class:
51 hideEmptyMembersBox: true
52 ---
53 classDiagram
55 class PathStatus
56 click PathStatus href "#yuio.git.PathStatus" "yuio.git.PathStatus"
58 class FileStatus
59 click FileStatus href "#yuio.git.FileStatus" "yuio.git.FileStatus"
60 PathStatus <|-- FileStatus
62 class SubmoduleStatus
63 click SubmoduleStatus href "#yuio.git.SubmoduleStatus" "yuio.git.SubmoduleStatus"
64 FileStatus <|-- SubmoduleStatus
66 class UnmergedFileStatus
67 click UnmergedFileStatus href "#yuio.git.UnmergedFileStatus" "yuio.git.UnmergedFileStatus"
68 PathStatus <|-- UnmergedFileStatus
70 class UnmergedSubmoduleStatus
71 click UnmergedSubmoduleStatus href "#yuio.git.UnmergedSubmoduleStatus" "yuio.git.UnmergedSubmoduleStatus"
72 UnmergedFileStatus <|-- UnmergedSubmoduleStatus
73 </pre>
74 </p>
76.. autoclass:: Status
77 :members:
79.. autoclass:: PathStatus
80 :members:
82.. autoclass:: FileStatus
83 :members:
85.. autoclass:: SubmoduleStatus
86 :members:
88.. autoclass:: UnmergedFileStatus
89 :members:
91.. autoclass:: UnmergedSubmoduleStatus
92 :members:
94.. autoclass:: Modification
95 :members:
98Commit objects
99--------------
101.. autoclass:: Commit
102 :members:
104.. autoclass:: CommitTrailers
105 :members:
108Parsing git refs
109----------------
111When you need to query a git ref from a user, :class:`RefParser` will check
112that the given ref is formatted correctly. Use :class:`Ref` in your type hints
113to help Yuio detect that you want to parse it as a git reference:
115.. autoclass:: Ref
117.. autoclass:: Tag
119.. autoclass:: Branch
121.. autoclass:: Remote
123.. autoclass:: RefParser
125.. autoclass:: TagParser
127.. autoclass:: BranchParser
129.. autoclass:: RemoteParser
131If you know path to your repository before hand, and want to make sure that the user
132supplies a valid ref that points to an existing git object, use :class:`CommitParser`:
134.. autoclass:: CommitParser
137Autocompleting git refs
138-----------------------
140.. autoclass:: RefCompleter
142.. autoclass:: RefCompleterMode
143 :members:
145"""
147from __future__ import annotations
149import dataclasses
150import enum
151import functools
152import logging
153import pathlib
154import re
155from dataclasses import dataclass
156from datetime import datetime
158import yuio.complete
159import yuio.exec
160import yuio.parse
161from yuio.util import dedent as _dedent
163import yuio._typing_ext as _tx
164from typing import TYPE_CHECKING
166if TYPE_CHECKING:
167 import typing_extensions as _t
168else:
169 from yuio import _typing as _t
171__all__ = [
172 "Branch",
173 "BranchParser",
174 "Commit",
175 "CommitParser",
176 "CommitTrailers",
177 "FileStatus",
178 "GitError",
179 "GitExecError",
180 "GitUnavailableError",
181 "Modification",
182 "NotARepositoryError",
183 "PathStatus",
184 "Ref",
185 "RefCompleter",
186 "RefCompleterMode",
187 "RefParser",
188 "Remote",
189 "RemoteParser",
190 "Repo",
191 "Status",
192 "SubmoduleStatus",
193 "Tag",
194 "TagParser",
195 "UnmergedFileStatus",
196 "UnmergedSubmoduleStatus",
197]
199_logger = logging.getLogger(__name__)
202class GitError(Exception):
203 """
204 Raised when interaction with git fails.
206 """
209class GitExecError(GitError, yuio.exec.ExecError):
210 """
211 Raised when git returns a non-zero exit code.
213 """
216class GitUnavailableError(GitError, FileNotFoundError):
217 """
218 Raised when git executable can't be found.
220 """
223class NotARepositoryError(GitError, FileNotFoundError):
224 """
225 Raised when given path is not in git repository.
227 """
230Ref = _t.NewType("Ref", str)
231"""
232A special kind of string that contains a git object reference.
234Ref is not guaranteed to be valid; this type is used in type hints
235to make use of the :class:`RefParser`.
237"""
239Tag = _t.NewType("Tag", str)
240"""
241A special kind of string that contains a tag name.
243Ref is not guaranteed to be valid; this type is used in type hints
244to make use of the :class:`TagParser`.
246"""
248Branch = _t.NewType("Branch", str)
249"""
250A special kind of string that contains a branch name.
252Ref is not guaranteed to be valid; this type is used in type hints
253to make use of the :class:`BranchParser`.
255"""
257Remote = _t.NewType("Remote", str)
258"""
259A special kind of string that contains a remote branch name.
261Ref is not guaranteed to be valid; this type is used in type hints
262to make use of the :class:`RemoteParser`.
264"""
267# See https://git-scm.com/docs/git-log#_pretty_formats
268# for explanation of these incantations.
269_LOG_FMT = "%H%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%(decorate:prefix=,suffix=,tag=,separator= )%n%w(0,0,1)%B%w(0,0)%n-"
270_LOG_TRAILERS_FMT = "%H%n%w(0,1,1)%(trailers:only=true)%w(0,0)%n-"
271_LOG_TRAILER_KEY_RE = re.compile(r"^(?P<key>\S+):\s")
274class Repo:
275 """
276 A class that allows interactions with a git repository.
278 :param path:
279 path to the repo root dir.
280 :param env:
281 environment variables for the git executable.
282 :raises:
283 constructor of this class may raise :class:`GitError` if git isn't available
284 or if the given part is not inside of a git repository.
286 """
288 def __init__(
289 self,
290 path: pathlib.Path | str,
291 /,
292 env: dict[str, str] | None = None,
293 ):
294 self.__path = pathlib.Path(path)
295 self.__env = env
296 self.__git_is_available = None
297 self.__is_repo = None
299 try:
300 version = self.git("--version")
301 except GitExecError:
302 raise GitUnavailableError("git executable is not available")
304 _logger.debug("%s", version.decode(errors="replace").strip())
306 try:
307 self.git("rev-parse", "--is-inside-work-tree")
308 except GitExecError:
309 raise NotARepositoryError(f"{self.__path} is not a git repository")
311 @property
312 def path(self) -> pathlib.Path:
313 """
314 Path to the repo, as was passed to the constructor.
316 """
318 return self.__path
320 @functools.cached_property
321 def root(self) -> pathlib.Path:
322 """
323 The root directory of the repo.
325 """
327 return pathlib.Path(
328 self.git("rev-parse", "--path-format=absolute", "--show-toplevel")
329 .decode()
330 .strip()
331 ).resolve()
333 @functools.cached_property
334 def git_dir(self) -> pathlib.Path:
335 """
336 Get path to the ``.git`` directory of the repo.
338 """
340 return pathlib.Path(
341 self.git("rev-parse", "--path-format=absolute", "--git-dir")
342 .decode()
343 .strip()
344 ).resolve()
346 @_t.overload
347 def git(self, *args: str | pathlib.Path) -> bytes: ...
348 @_t.overload
349 def git(self, *args: str | pathlib.Path, capture_io: _t.Literal[False]) -> None: ...
350 @_t.overload
351 def git(self, *args: str | pathlib.Path, capture_io: bool) -> bytes | None: ...
352 def git(self, *args: str | pathlib.Path, capture_io: bool = True):
353 """
354 Call git and return its stdout.
356 :param args:
357 arguments for the :flag:`git` command.
358 :param capture_io:
359 If set to :data:`False`, command's stderr and stdout are not captured.
360 :returns:
361 output of the git command.
362 :raises:
363 :class:`GitError`, :class:`OSError`.
365 """
367 try:
368 return yuio.exec.exec(
369 "git",
370 *args,
371 cwd=self.__path,
372 env=self.__env,
373 capture_io=capture_io,
374 text=False,
375 )
376 except yuio.exec.ExecError as e:
377 raise GitExecError(e.returncode, e.cmd, e.output, e.stderr)
378 except FileNotFoundError:
379 raise GitUnavailableError("git executable not found")
381 def status(
382 self, /, include_ignored: bool = False, include_submodules: bool = True
383 ) -> Status:
384 """
385 Query the current repository status.
387 :param include_ignored:
388 include ignored status in the list of changes. Disable by default.
389 :param include_submodules:
390 include status of submodules in the list of changes. Enabled by default.
391 :returns:
392 current repository status.
393 :raises:
394 :class:`GitError`, :class:`OSError`.
396 """
398 text = self.git(
399 "status",
400 "--porcelain=v2",
401 "-z",
402 "--ahead-behind",
403 "--branch",
404 "--renames",
405 "--untracked-files=normal",
406 "--ignore-submodules=" + ("none" if include_submodules else "all"),
407 "--ignored=" + ("matching" if include_ignored else "no"),
408 )
409 lines = iter(text.split(b"\0"))
411 status = Status(commit=None)
413 for line_b in lines:
414 line = line_b.decode()
415 if line.startswith("# branch.oid"):
416 if line[13:] != "(initial)":
417 status.commit = line[13:]
418 elif line.startswith("# branch.head"):
419 if line[14:] != "(detached)":
420 status.branch = line[14:]
421 elif line.startswith("# branch.upstream"):
422 status.upstream = line[18:]
423 elif line.startswith("# branch.ab"):
424 match = re.match(r"^\+(\d+) -(\d+)$", line[12:])
425 assert match is not None
426 status.ahead = int(match.group(1))
427 status.behind = int(match.group(2))
428 elif line.startswith("1"):
429 match = re.match(
430 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){5}(?P<path>.*)$",
431 line[2:],
432 )
433 assert match is not None
434 sub = match.group("sub")
435 if sub[0] == "S":
436 path_status = SubmoduleStatus(
437 path=pathlib.Path(match.group("path")),
438 path_from=None,
439 staged=Modification(match.group("X")),
440 tree=Modification(match.group("Y")),
441 commit_changed=sub[1] != ".",
442 has_tracked_changes=sub[2] != ".",
443 has_untracked_changes=sub[3] != ".",
444 )
445 else:
446 path_status = FileStatus(
447 path=pathlib.Path(match.group("path")),
448 path_from=None,
449 staged=Modification(match.group("X")),
450 tree=Modification(match.group("Y")),
451 )
452 status.changes.append(path_status)
453 elif line.startswith("2"):
454 match = re.match(
455 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){6}(?P<path>.*)$",
456 line[2:],
457 )
458 assert match is not None
459 path_from = pathlib.Path(next(lines).decode())
460 sub = match.group("sub")
461 if sub[0] == "S":
462 path_status = SubmoduleStatus(
463 path=pathlib.Path(match.group("path")),
464 path_from=path_from,
465 staged=Modification(match.group("X")),
466 tree=Modification(match.group("Y")),
467 commit_changed=sub[1] != ".",
468 has_tracked_changes=sub[2] != ".",
469 has_untracked_changes=sub[3] != ".",
470 )
471 else:
472 path_status = FileStatus(
473 path=pathlib.Path(match.group("path")),
474 path_from=path_from,
475 staged=Modification(match.group("X")),
476 tree=Modification(match.group("Y")),
477 )
478 status.changes.append(path_status)
479 elif line.startswith("u"):
480 match = re.match(
481 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){7}(?P<path>.*)$",
482 line[2:],
483 )
484 assert match is not None
485 sub = match.group("sub")
486 if sub[0] == "S":
487 path_status = UnmergedSubmoduleStatus(
488 path=pathlib.Path(match.group("path")),
489 us=Modification(match.group("X")),
490 them=Modification(match.group("Y")),
491 commit_changed=sub[1] != ".",
492 has_tracked_changes=sub[2] != ".",
493 has_untracked_changes=sub[3] != ".",
494 )
495 else:
496 path_status = UnmergedFileStatus(
497 path=pathlib.Path(match.group("path")),
498 us=Modification(match.group("X")),
499 them=Modification(match.group("Y")),
500 )
501 status.changes.append(path_status)
502 elif line.startswith("?"):
503 status.changes.append(
504 FileStatus(
505 path=pathlib.Path(line[2:]),
506 path_from=None,
507 staged=Modification.UNTRACKED,
508 tree=Modification.UNTRACKED,
509 )
510 )
511 elif line.startswith("!"):
512 status.changes.append(
513 FileStatus(
514 path=pathlib.Path(line[2:]),
515 path_from=None,
516 staged=Modification.IGNORED,
517 tree=Modification.IGNORED,
518 )
519 )
521 try:
522 status.cherry_pick_head = (
523 self.git("rev-parse", "--verify", "CHERRY_PICK_HEAD").decode().strip()
524 )
525 except GitError:
526 pass
527 try:
528 status.merge_head = (
529 self.git("rev-parse", "--verify", "MERGE_HEAD").decode().strip()
530 )
531 except GitError:
532 pass
533 try:
534 status.rebase_head = (
535 self.git("rev-parse", "--verify", "REBASE_HEAD").decode().strip()
536 )
537 except GitError:
538 pass
539 try:
540 status.revert_head = (
541 self.git("rev-parse", "--verify", "REVERT_HEAD").decode().strip()
542 )
543 except GitError:
544 pass
545 try:
546 status.bisect_start = (
547 self.git("rev-parse", "--verify", "BISECT_START").decode().strip()
548 )
549 except GitError:
550 pass
552 return status
554 def print_status(self):
555 """
556 Run :flag:`git status` and show its output to the user.
558 """
560 self.git("status", capture_io=False)
562 def log(self, *refs: str, max_entries: int | None = None) -> list[Commit]:
563 """
564 Query the log for given git objects.
566 :param refs:
567 git references that will be passed to :flag:`git log`.
568 :param max_entries:
569 maximum number of returned references.
570 :returns:
571 list of found commits.
572 :raises:
573 :class:`GitError`, :class:`OSError`.
575 """
577 args = [
578 f"--pretty=format:{_LOG_FMT}",
579 "--decorate-refs=refs/tags",
580 "--decorate=short",
581 ]
583 if max_entries is not None:
584 args += ["-n", str(max_entries)]
586 args += list(refs)
588 text = self.git("log", *args)
589 lines = iter(text.decode().split("\n"))
591 commits = []
593 while commit := self.__parse_single_log_entry(lines):
594 commits.append(commit)
596 return commits
598 def trailers(
599 self, *refs: str, max_entries: int | None = None
600 ) -> list[CommitTrailers]:
601 """
602 Query trailer lines for given git objects.
604 Trailers are lines at the end of a commit message formatted as ``key: value``
605 pairs. See `git-interpret-trailers`__ for further description.
607 __ https://git-scm.com/docs/git-interpret-trailers
609 :param refs:
610 git references that will be passed to :flag:`git log`.
611 :param max_entries:
612 maximum number of checked commits.
614 .. warning::
616 This option limits number of checked commits, not the number
617 of trailers.
618 :returns:
619 list of found commits and their trailers.
620 :raises:
621 :class:`GitError`, :class:`OSError`.
623 """
625 args = [f"--pretty=format:{_LOG_TRAILERS_FMT}"]
627 if max_entries is not None:
628 args += ["-n", str(max_entries)]
630 args += list(refs)
632 text = self.git("log", *args)
633 lines = iter(text.decode().split("\n"))
635 trailers = []
637 while commit := self.__parse_single_trailer_entry(lines):
638 trailers.append(commit)
640 return trailers
642 def show(self, ref: str, /) -> Commit | None:
643 """
644 Query information for the given git object.
646 Return :data:`None` if object is not found.
648 :param ref:
649 git reference that will be passed to :flag:`git log`.
650 :returns:
651 found commit or :data:`None`.
652 :raises:
653 :class:`OSError`.
655 """
657 try:
658 self.git("rev-parse", "--verify", ref)
659 except GitError:
660 return None
662 log = self.log(ref, max_entries=1)
663 if not log:
664 return None
665 else:
666 commit = log[0]
667 commit.orig_ref = ref
668 return commit
670 @staticmethod
671 def __parse_single_log_entry(lines) -> Commit | None:
672 try:
673 commit = next(lines)
674 author = next(lines)
675 author_email = next(lines)
676 author_datetime = datetime.fromisoformat(next(lines).replace("Z", "+00:00"))
677 committer = next(lines)
678 committer_email = next(lines)
679 committer_datetime = datetime.fromisoformat(
680 next(lines).replace("Z", "+00:00")
681 )
682 tags = next(lines).split()
683 title = next(lines)
684 body = ""
686 while True:
687 line = next(lines)
688 if not line or line.startswith(" "):
689 body += line[1:] + "\n"
690 else:
691 break
693 body = body.strip("\n")
694 if body:
695 body += "\n"
697 return Commit(
698 hash=commit,
699 tags=tags,
700 author=author,
701 author_email=author_email,
702 author_datetime=author_datetime,
703 committer=committer,
704 committer_email=committer_email,
705 committer_datetime=committer_datetime,
706 title=title,
707 body=body,
708 )
709 except StopIteration:
710 return None
712 @staticmethod
713 def __parse_single_trailer_entry(
714 lines,
715 ) -> CommitTrailers | None:
716 try:
717 commit = next(lines)
718 trailers = []
719 current_key = None
720 current_value = ""
722 while True:
723 line = next(lines)
724 if not line or line.startswith(" "):
725 line = line[1:] + "\n"
726 if match := _LOG_TRAILER_KEY_RE.match(line):
727 if current_key:
728 current_value = _dedent(current_value)
729 trailers.append((current_key, current_value))
730 current_key = match.group("key")
731 current_value = line[match.end() :]
732 else:
733 current_value += line
734 else:
735 break
736 if current_key:
737 current_value = _dedent(current_value)
738 trailers.append((current_key, current_value))
740 return CommitTrailers(hash=commit, trailers=trailers)
741 except StopIteration:
742 return None
744 def tags(self) -> list[str]:
745 """
746 List all tags in this repository.
748 :returns:
749 list of strings representing tags, without ``refs/tags`` prefix,
750 sorted lexicographically as strings.
751 :raises:
752 :class:`GitError`, :class:`OSError`.
754 """
756 return (
757 self.git("for-each-ref", "--format=%(refname:short)", "refs/tags")
758 .decode()
759 .splitlines()
760 )
762 def branches(self) -> list[str]:
763 """
764 List all branches in this repository.
766 :returns:
767 list of strings representing branch names, without ``refs/heads`` prefix,
768 sorted lexicographically as strings.
769 :raises:
770 :class:`GitError`, :class:`OSError`.
772 """
774 return (
775 self.git("for-each-ref", "--format=%(refname:short)", "refs/heads")
776 .decode()
777 .splitlines()
778 )
780 def remotes(self) -> list[str]:
781 """
782 List all remote branches in this repository.
784 :returns:
785 list of strings representing remote branches, without
786 ``refs/remotes`` prefix, sorted lexicographically as strings.
787 :raises:
788 :class:`GitError`, :class:`OSError`.
790 """
792 return [
793 remote
794 for remote in self.git(
795 "for-each-ref", "--format=%(refname:short)", "refs/remotes"
796 )
797 .decode()
798 .splitlines()
799 if "/" in remote
800 ]
803@dataclass(kw_only=True, slots=True)
804class Commit:
805 """
806 Commit description.
808 """
810 hash: str
811 """
812 Commit hash.
814 """
816 tags: list[str]
817 """
818 Tags attached to this commit.
820 """
822 author: str
823 """
824 Author name.
826 """
828 author_email: str
829 """
830 Author email.
832 """
834 author_datetime: datetime
835 """
836 Author time.
838 """
840 committer: str
841 """
842 Committer name.
844 """
846 committer_email: str
847 """
848 Committer email.
850 """
852 committer_datetime: datetime
853 """
854 Committer time.
856 """
858 title: str
859 """
860 Commit title, i.e. first line of the message.
862 """
864 body: str
865 """
866 Commit body, i.e. the rest of the message.
868 """
870 orig_ref: str | None = None
871 """
872 If commit was parsed from a user input, this field will contain
873 original input. I.e. if a user enters ``HEAD`` and it gets resolved
874 into a commit, `orig_ref` will contain string ``"HEAD"``.
876 See also :class:`CommitParser`.
878 """
880 @property
881 def short_hash(self):
882 """
883 First seven characters of the commit hash.
885 """
887 return self.hash[:7]
889 def __str__(self):
890 if self.orig_ref:
891 return self.orig_ref
892 else:
893 return self.short_hash
896@dataclass(kw_only=True, slots=True)
897class CommitTrailers:
898 """
899 Commit trailers.
901 """
903 hash: str
904 """
905 Commit hash.
907 """
909 trailers: list[tuple[str, str]]
910 """
911 Key-value pairs for commit trailers.
913 """
916class Modification(enum.Enum):
917 """
918 For changed file or submodule, what modification was applied to it.
920 """
922 UNMODIFIED = "."
923 """
924 File wasn't changed.
926 """
928 MODIFIED = "M"
929 """
930 File was changed.
932 """
934 SUBMODULE_MODIFIED = "m"
935 """
936 Contents of submodule were modified.
938 """
940 TYPE_CHANGED = "T"
941 """
942 File type changed.
944 """
946 ADDED = "A"
947 """
948 File was created.
950 """
952 DELETED = "D"
953 """
954 File was deleted.
956 """
958 RENAMED = "R"
959 """
960 File was renamed (and possibly changed).
962 """
964 COPIED = "C"
965 """
966 File was copied (and possibly changed).
968 """
970 UPDATED = "U"
971 """
972 File was updated but unmerged.
974 """
976 UNTRACKED = "?"
977 """
978 File is untracked, i.e. not yet staged or committed.
980 """
982 IGNORED = "!"
983 """
984 File is in ``.gitignore``.
986 """
989@dataclass(kw_only=True, slots=True)
990class PathStatus:
991 """
992 Status of a changed path.
994 """
996 path: pathlib.Path
997 """
998 Path of the file.
1000 """
1003@dataclass(kw_only=True, slots=True)
1004class FileStatus(PathStatus):
1005 """
1006 Status of a changed file.
1008 """
1010 path_from: pathlib.Path | None
1011 """
1012 If file was moved, contains path where it was moved from.
1014 """
1016 staged: Modification
1017 """
1018 File modification in the index (staged).
1020 """
1022 tree: Modification
1023 """
1024 File modification in the tree (unstaged).
1026 """
1029@dataclass(kw_only=True, slots=True)
1030class SubmoduleStatus(FileStatus):
1031 """
1032 Status of a submodule.
1034 """
1036 commit_changed: bool
1037 """
1038 The submodule has a different HEAD than recorded in the index.
1040 """
1042 has_tracked_changes: bool
1043 """
1044 Tracked files were changed in the submodule.
1046 """
1048 has_untracked_changes: bool
1049 """
1050 Untracked files were changed in the submodule.
1052 """
1055@dataclass(kw_only=True, slots=True)
1056class UnmergedFileStatus(PathStatus):
1057 """
1058 Status of an unmerged file.
1060 """
1062 us: Modification
1063 """
1064 File modification that has happened at the head.
1066 """
1068 them: Modification
1069 """
1070 File modification that has happened at the merge head.
1072 """
1075@dataclass(kw_only=True, slots=True)
1076class UnmergedSubmoduleStatus(UnmergedFileStatus):
1077 """
1078 Status of an unmerged submodule.
1080 """
1082 commit_changed: bool
1083 """
1084 The submodule has a different HEAD than recorded in the index.
1086 """
1088 has_tracked_changes: bool
1089 """
1090 Tracked files were changed in the submodule.
1092 """
1094 has_untracked_changes: bool
1095 """
1096 Untracked files were changed in the submodule.
1098 """
1101@dataclass(kw_only=True, slots=True)
1102class Status:
1103 """
1104 Status of a working copy.
1106 """
1108 commit: str | None
1109 """
1110 Current commit hash. Can be absent if current branch is orphaned and doesn't have
1111 any commits yet.
1113 """
1115 branch: str | None = None
1116 """
1117 Name of the current branch.
1119 """
1121 upstream: str | None = None
1122 """
1123 Name of the upstream branch.
1125 """
1127 ahead: int | None = None
1128 """
1129 Number of commits the branch is ahead of upstream.
1131 """
1133 behind: int | None = None
1134 """
1135 Number of commits the branch is behind of upstream.
1137 """
1139 changes: list[PathStatus] = dataclasses.field(default_factory=list)
1140 """
1141 List of changed files, both tracked and untracked.
1143 See details about change representation in `git status`__ manual.
1145 __ https://git-scm.com/docs/git-status#_output
1147 """
1149 cherry_pick_head: str | None = None
1150 """
1151 Position of the ``CHERRY_PICK_HEAD``.
1153 If this field is not :data:`None`, cherry pick is in progress.
1155 """
1157 merge_head: str | None = None
1158 """
1159 Position of the ``MERGE_HEAD``.
1161 If this field is not :data:`None`, merge is in progress.
1163 """
1165 rebase_head: str | None = None
1166 """
1167 Position of the ``REBASE_HEAD``.
1169 If this field is not :data:`None`, rebase is in progress.
1171 """
1173 revert_head: str | None = None
1174 """
1175 Position of the ``REVERT_HEAD``.
1177 If this field is not :data:`None`, revert is in progress.
1179 """
1181 bisect_start: str | None = None
1182 """
1183 Position of the ``BISECT_START``.
1185 If this field is not :data:`None`, bisect is in progress.
1187 """
1189 def has_staged_changes(self) -> bool:
1190 """
1191 Return :data:`True` if there are unstaged changes in this repository.
1193 """
1195 return next(self.get_staged_changes(), None) is not None
1197 def get_staged_changes(self) -> _t.Iterator[PathStatus]:
1198 """
1199 Return iterator over all unstaged changes in this repository.
1201 """
1203 return (
1204 change
1205 for change in self.changes
1206 if isinstance(change, FileStatus)
1207 and change.staged
1208 not in [
1209 Modification.UNMODIFIED,
1210 Modification.IGNORED,
1211 Modification.UNTRACKED,
1212 ]
1213 )
1215 def has_unstaged_changes(self) -> bool:
1216 """
1217 Return :data:`True` if there are unstaged changes in this repository.
1219 """
1221 return next(self.get_unstaged_changes(), None) is not None
1223 def get_unstaged_changes(self) -> _t.Iterator[PathStatus]:
1224 """
1225 Return iterator over all staged changes in this repository.
1227 """
1229 return (
1230 change
1231 for change in self.changes
1232 if isinstance(change, UnmergedFileStatus)
1233 or (
1234 isinstance(change, FileStatus)
1235 and change.tree
1236 not in [
1237 Modification.UNMODIFIED,
1238 Modification.IGNORED,
1239 ]
1240 )
1241 )
1243 def has_ongoing_operation(self) -> bool:
1244 """
1245 Return :data:`True` if there is an ongoing operation such as merge or rebase.
1247 """
1249 return (
1250 self.cherry_pick_head is not None
1251 or self.merge_head is not None
1252 or self.rebase_head is not None
1253 or self.revert_head is not None
1254 or self.bisect_start is not None
1255 )
1258class RefCompleterMode(enum.Enum):
1259 """
1260 Specifies operation modes for :class:`RefCompleter`.
1262 """
1264 BRANCH = "b"
1265 """
1266 Completes branches.
1268 """
1270 REMOTE = "r"
1271 """
1272 Completes remote branches.
1274 """
1276 TAG = "t"
1277 """
1278 Completes tags.
1280 """
1282 HEAD = "h"
1283 """
1284 Completes ``HEAD`` and ``ORIG_HEAD``.
1286 """
1289class RefCompleter(yuio.complete.Completer):
1290 """
1291 Completes git refs.
1293 :param repo:
1294 source of completions. If not given, this completer will try to use current
1295 directory as a repo root, and fail silently if it's not a repo.
1296 :param modes:
1297 which objects to complete.
1299 """
1301 def __init__(self, repo: Repo | None, modes: set[RefCompleterMode] | None = None):
1302 super().__init__()
1304 self._repo: Repo | None | _t.Literal[False] = repo
1305 self._modes = modes or {
1306 RefCompleterMode.BRANCH,
1307 RefCompleterMode.TAG,
1308 RefCompleterMode.HEAD,
1309 }
1311 def _process(self, collector: yuio.complete.CompletionCollector, /):
1312 if self._repo is None:
1313 try:
1314 self._repo = Repo(pathlib.Path.cwd())
1315 except (GitError, OSError):
1316 self._repo = False
1317 if not self._repo:
1318 return
1319 try:
1320 if RefCompleterMode.HEAD in self._modes:
1321 collector.add_group()
1322 git_dir = self._repo.git_dir
1323 for head in ["HEAD", "ORIG_HEAD"]:
1324 if (git_dir / head).exists():
1325 collector.add(head)
1326 if RefCompleterMode.BRANCH in self._modes:
1327 collector.add_group()
1328 for branch in self._repo.branches():
1329 collector.add(branch, comment="branch")
1330 if RefCompleterMode.REMOTE in self._modes:
1331 collector.add_group()
1332 for remote in self._repo.remotes():
1333 collector.add(remote, comment="remote")
1334 if RefCompleterMode.TAG in self._modes:
1335 collector.add_group()
1336 for tag in self._repo.tags():
1337 collector.add(tag, comment="tag")
1338 except GitError:
1339 pass
1341 def _get_completion_model(
1342 self, *, is_many: bool = False
1343 ) -> yuio.complete._OptionSerializer.Model:
1344 return yuio.complete._OptionSerializer.Git(
1345 "".join(sorted(list(m.value for m in self._modes)))
1346 )
1349def CommitParser(*, repo: Repo) -> yuio.parse.Parser[Commit]:
1350 """
1351 A parser for git refs (commits, tags, branches, and so on).
1353 This parser validates that the given ref exists in the given repository,
1354 parses it and returns a commit data associated with this ref.
1356 If you need a simple string without additional validation,
1357 use :class:`RefParser`.
1359 :param repo:
1360 initialized repository is required to ensure that commit is valid.
1362 """
1364 def map(value: str, /) -> Commit:
1365 commit = repo.show(value)
1366 if commit is None:
1367 raise yuio.parse.ParsingError("invalid git ref `%s`", value)
1368 return commit
1370 def rev(value: Commit | object) -> str:
1371 if isinstance(value, Commit):
1372 return str(value)
1373 else:
1374 raise TypeError(
1375 f"parser Commit can't handle value of type {_tx.type_repr(type(value))}"
1376 )
1378 return yuio.parse.WithMeta(
1379 yuio.parse.Map(yuio.parse.Str(), map, rev),
1380 desc="<commit>",
1381 completer=RefCompleter(repo),
1382 )
1385T = _t.TypeVar("T")
1388class _RefParserImpl(yuio.parse.Str, _t.Generic[T]):
1389 @functools.cached_property
1390 def _description(self):
1391 return "<" + self.__class__.__name__.removesuffix("Parser").lower() + ">"
1393 def describe(self) -> str | None:
1394 return self._description
1396 def describe_or_def(self) -> str:
1397 return self._description
1399 def describe_many(self) -> str | tuple[str, ...]:
1400 return self._description
1403class RefParser(_RefParserImpl[Ref]):
1404 """
1405 A parser that provides autocompletion for git refs, but doesn't verify
1406 anything else.
1408 """
1410 def completer(self) -> yuio.complete.Completer:
1411 return RefCompleter(None)
1414class TagParser(_RefParserImpl[Tag]):
1415 """
1416 A parser that provides autocompletion for git tag, but doesn't verify
1417 anything else.
1419 """
1421 def completer(self) -> yuio.complete.Completer:
1422 return RefCompleter(None, {RefCompleterMode.TAG})
1425class BranchParser(_RefParserImpl[Branch]):
1426 """
1427 A parser that provides autocompletion for git branches, but doesn't verify
1428 anything else.
1430 """
1432 def completer(self) -> yuio.complete.Completer:
1433 return RefCompleter(None, {RefCompleterMode.BRANCH})
1436class RemoteParser(_RefParserImpl[Remote]):
1437 """
1438 A parser that provides autocompletion for git remotes, but doesn't verify
1439 anything else.
1441 """
1443 def completer(self) -> yuio.complete.Completer:
1444 return RefCompleter(None, {RefCompleterMode.REMOTE})
1447yuio.parse.register_type_hint_conversion(
1448 lambda ty, origin, args: RefParser() if ty is Ref else None
1449)
1451yuio.parse.register_type_hint_conversion(
1452 lambda ty, origin, args: TagParser() if ty is Tag else None
1453)
1455yuio.parse.register_type_hint_conversion(
1456 lambda ty, origin, args: BranchParser() if ty is Branch else None
1457)
1459yuio.parse.register_type_hint_conversion(
1460 lambda ty, origin, args: RemoteParser() if ty is Remote else None
1461)