Coverage for yuio / git.py: 97%
426 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +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: ...
349 @_t.overload
350 def git(self, *args: str | pathlib.Path, capture_io: _t.Literal[False]) -> None: ...
352 @_t.overload
353 def git(self, *args: str | pathlib.Path, capture_io: bool) -> bytes | None: ...
355 def git(self, *args: str | pathlib.Path, capture_io: bool = True):
356 """
357 Call git and return its stdout.
359 :param args:
360 arguments for the :flag:`git` command.
361 :param capture_io:
362 If set to :data:`False`, command's stderr and stdout are not captured.
363 :returns:
364 output of the git command.
365 :raises:
366 :class:`GitError`, :class:`OSError`.
368 """
370 try:
371 return yuio.exec.exec(
372 "git",
373 *args,
374 cwd=self.__path,
375 env=self.__env,
376 capture_io=capture_io,
377 text=False,
378 )
379 except yuio.exec.ExecError as e:
380 raise GitExecError(e.returncode, e.cmd, e.output, e.stderr)
381 except FileNotFoundError:
382 raise GitUnavailableError("git executable not found")
384 def status(
385 self, /, include_ignored: bool = False, include_submodules: bool = True
386 ) -> Status:
387 """
388 Query the current repository status.
390 :param include_ignored:
391 include ignored status in the list of changes. Disable by default.
392 :param include_submodules:
393 include status of submodules in the list of changes. Enabled by default.
394 :returns:
395 current repository status.
396 :raises:
397 :class:`GitError`, :class:`OSError`.
399 """
401 text = self.git(
402 "status",
403 "--porcelain=v2",
404 "-z",
405 "--ahead-behind",
406 "--branch",
407 "--renames",
408 "--untracked-files=normal",
409 "--ignore-submodules=" + ("none" if include_submodules else "all"),
410 "--ignored=" + ("matching" if include_ignored else "no"),
411 )
412 lines = iter(text.split(b"\0"))
414 status = Status(commit=None)
416 for line_b in lines:
417 line = line_b.decode()
418 if line.startswith("# branch.oid"):
419 if line[13:] != "(initial)":
420 status.commit = line[13:]
421 elif line.startswith("# branch.head"):
422 if line[14:] != "(detached)":
423 status.branch = line[14:]
424 elif line.startswith("# branch.upstream"):
425 status.upstream = line[18:]
426 elif line.startswith("# branch.ab"):
427 match = re.match(r"^\+(\d+) -(\d+)$", line[12:])
428 assert match is not None
429 status.ahead = int(match.group(1))
430 status.behind = int(match.group(2))
431 elif line.startswith("1"):
432 match = re.match(
433 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){5}(?P<path>.*)$",
434 line[2:],
435 )
436 assert match is not None
437 sub = match.group("sub")
438 if sub[0] == "S":
439 path_status = SubmoduleStatus(
440 path=pathlib.Path(match.group("path")),
441 path_from=None,
442 staged=Modification(match.group("X")),
443 tree=Modification(match.group("Y")),
444 commit_changed=sub[1] != ".",
445 has_tracked_changes=sub[2] != ".",
446 has_untracked_changes=sub[3] != ".",
447 )
448 else:
449 path_status = FileStatus(
450 path=pathlib.Path(match.group("path")),
451 path_from=None,
452 staged=Modification(match.group("X")),
453 tree=Modification(match.group("Y")),
454 )
455 status.changes.append(path_status)
456 elif line.startswith("2"):
457 match = re.match(
458 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){6}(?P<path>.*)$",
459 line[2:],
460 )
461 assert match is not None
462 path_from = pathlib.Path(next(lines).decode())
463 sub = match.group("sub")
464 if sub[0] == "S":
465 path_status = SubmoduleStatus(
466 path=pathlib.Path(match.group("path")),
467 path_from=path_from,
468 staged=Modification(match.group("X")),
469 tree=Modification(match.group("Y")),
470 commit_changed=sub[1] != ".",
471 has_tracked_changes=sub[2] != ".",
472 has_untracked_changes=sub[3] != ".",
473 )
474 else:
475 path_status = FileStatus(
476 path=pathlib.Path(match.group("path")),
477 path_from=path_from,
478 staged=Modification(match.group("X")),
479 tree=Modification(match.group("Y")),
480 )
481 status.changes.append(path_status)
482 elif line.startswith("u"):
483 match = re.match(
484 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){7}(?P<path>.*)$",
485 line[2:],
486 )
487 assert match is not None
488 sub = match.group("sub")
489 if sub[0] == "S":
490 path_status = UnmergedSubmoduleStatus(
491 path=pathlib.Path(match.group("path")),
492 us=Modification(match.group("X")),
493 them=Modification(match.group("Y")),
494 commit_changed=sub[1] != ".",
495 has_tracked_changes=sub[2] != ".",
496 has_untracked_changes=sub[3] != ".",
497 )
498 else:
499 path_status = UnmergedFileStatus(
500 path=pathlib.Path(match.group("path")),
501 us=Modification(match.group("X")),
502 them=Modification(match.group("Y")),
503 )
504 status.changes.append(path_status)
505 elif line.startswith("?"):
506 status.changes.append(
507 FileStatus(
508 path=pathlib.Path(line[2:]),
509 path_from=None,
510 staged=Modification.UNTRACKED,
511 tree=Modification.UNTRACKED,
512 )
513 )
514 elif line.startswith("!"):
515 status.changes.append(
516 FileStatus(
517 path=pathlib.Path(line[2:]),
518 path_from=None,
519 staged=Modification.IGNORED,
520 tree=Modification.IGNORED,
521 )
522 )
524 try:
525 status.cherry_pick_head = (
526 self.git("rev-parse", "--verify", "CHERRY_PICK_HEAD").decode().strip()
527 )
528 except GitError:
529 pass
530 try:
531 status.merge_head = (
532 self.git("rev-parse", "--verify", "MERGE_HEAD").decode().strip()
533 )
534 except GitError:
535 pass
536 try:
537 status.rebase_head = (
538 self.git("rev-parse", "--verify", "REBASE_HEAD").decode().strip()
539 )
540 except GitError:
541 pass
542 try:
543 status.revert_head = (
544 self.git("rev-parse", "--verify", "REVERT_HEAD").decode().strip()
545 )
546 except GitError:
547 pass
549 return status
551 def print_status(self):
552 """
553 Run :flag:`git status` and show its output to the user.
555 """
557 self.git("status", capture_io=False)
559 def log(self, *refs: str, max_entries: int | None = None) -> list[Commit]:
560 """
561 Query the log for given git objects.
563 :param refs:
564 git references that will be passed to :flag:`git log`.
565 :param max_entries:
566 maximum number of returned references.
567 :returns:
568 list of found commits.
569 :raises:
570 :class:`GitError`, :class:`OSError`.
572 """
574 args = [
575 f"--pretty=format:{_LOG_FMT}",
576 "--decorate-refs=refs/tags",
577 "--decorate=short",
578 ]
580 if max_entries is not None:
581 args += ["-n", str(max_entries)]
583 args += list(refs)
585 text = self.git("log", *args)
586 lines = iter(text.decode().split("\n"))
588 commits = []
590 while commit := self.__parse_single_log_entry(lines):
591 commits.append(commit)
593 return commits
595 def trailers(
596 self, *refs: str, max_entries: int | None = None
597 ) -> list[CommitTrailers]:
598 """
599 Query trailer lines for given git objects.
601 Trailers are lines at the end of a commit message formatted as ``key: value``
602 pairs. See `git-interpret-trailers`__ for further description.
604 __ https://git-scm.com/docs/git-interpret-trailers
606 :param refs:
607 git references that will be passed to :flag:`git log`.
608 :param max_entries:
609 maximum number of checked commits.
611 .. warning::
613 This option limits number of checked commits, not the number
614 of trailers.
615 :returns:
616 list of found commits and their trailers.
617 :raises:
618 :class:`GitError`, :class:`OSError`.
620 """
622 args = [f"--pretty=format:{_LOG_TRAILERS_FMT}"]
624 if max_entries is not None:
625 args += ["-n", str(max_entries)]
627 args += list(refs)
629 text = self.git("log", *args)
630 lines = iter(text.decode().split("\n"))
632 trailers = []
634 while commit := self.__parse_single_trailer_entry(lines):
635 trailers.append(commit)
637 return trailers
639 def show(self, ref: str, /) -> Commit | None:
640 """
641 Query information for the given git object.
643 Return :data:`None` if object is not found.
645 :param ref:
646 git reference that will be passed to :flag:`git log`.
647 :returns:
648 found commit or :data:`None`.
649 :raises:
650 :class:`OSError`.
652 """
654 try:
655 self.git("rev-parse", "--verify", ref)
656 except GitError:
657 return None
659 log = self.log(ref, max_entries=1)
660 if not log:
661 return None
662 else:
663 commit = log[0]
664 commit.orig_ref = ref
665 return commit
667 @staticmethod
668 def __parse_single_log_entry(lines) -> Commit | None:
669 try:
670 commit = next(lines)
671 author = next(lines)
672 author_email = next(lines)
673 author_datetime = datetime.fromisoformat(next(lines).replace("Z", "+00:00"))
674 committer = next(lines)
675 committer_email = next(lines)
676 committer_datetime = datetime.fromisoformat(
677 next(lines).replace("Z", "+00:00")
678 )
679 tags = next(lines).split()
680 title = next(lines)
681 body = ""
683 while True:
684 line = next(lines)
685 if not line or line.startswith(" "):
686 body += line[1:] + "\n"
687 else:
688 break
690 body = body.strip("\n")
691 if body:
692 body += "\n"
694 return Commit(
695 hash=commit,
696 tags=tags,
697 author=author,
698 author_email=author_email,
699 author_datetime=author_datetime,
700 committer=committer,
701 committer_email=committer_email,
702 committer_datetime=committer_datetime,
703 title=title,
704 body=body,
705 )
706 except StopIteration:
707 return None
709 @staticmethod
710 def __parse_single_trailer_entry(
711 lines,
712 ) -> CommitTrailers | None:
713 try:
714 commit = next(lines)
715 trailers = []
716 current_key = None
717 current_value = ""
719 while True:
720 line = next(lines)
721 if not line or line.startswith(" "):
722 line = line[1:] + "\n"
723 if match := _LOG_TRAILER_KEY_RE.match(line):
724 if current_key:
725 current_value = _dedent(current_value)
726 trailers.append((current_key, current_value))
727 current_key = match.group("key")
728 current_value = line[match.end() :]
729 else:
730 current_value += line
731 else:
732 break
733 if current_key:
734 current_value = _dedent(current_value)
735 trailers.append((current_key, current_value))
737 return CommitTrailers(hash=commit, trailers=trailers)
738 except StopIteration:
739 return None
741 def tags(self) -> list[str]:
742 """
743 List all tags in this repository.
745 :returns:
746 list of strings representing tags, without ``refs/tags`` prefix,
747 sorted lexicographically as strings.
748 :raises:
749 :class:`GitError`, :class:`OSError`.
751 """
753 return (
754 self.git("for-each-ref", "--format=%(refname:short)", "refs/tags")
755 .decode()
756 .splitlines()
757 )
759 def branches(self) -> list[str]:
760 """
761 List all branches in this repository.
763 :returns:
764 list of strings representing branch names, without ``refs/heads`` prefix,
765 sorted lexicographically as strings.
766 :raises:
767 :class:`GitError`, :class:`OSError`.
769 """
771 return (
772 self.git("for-each-ref", "--format=%(refname:short)", "refs/heads")
773 .decode()
774 .splitlines()
775 )
777 def remotes(self) -> list[str]:
778 """
779 List all remote branches in this repository.
781 :returns:
782 list of strings representing remote branches, without
783 ``refs/remotes`` prefix, sorted lexicographically as strings.
784 :raises:
785 :class:`GitError`, :class:`OSError`.
787 """
789 return [
790 remote
791 for remote in self.git(
792 "for-each-ref", "--format=%(refname:short)", "refs/remotes"
793 )
794 .decode()
795 .splitlines()
796 if "/" in remote
797 ]
800@dataclass(kw_only=True, slots=True)
801class Commit:
802 """
803 Commit description.
805 """
807 hash: str
808 """
809 Commit hash.
811 """
813 tags: list[str]
814 """
815 Tags attached to this commit.
817 """
819 author: str
820 """
821 Author name.
823 """
825 author_email: str
826 """
827 Author email.
829 """
831 author_datetime: datetime
832 """
833 Author time.
835 """
837 committer: str
838 """
839 Committer name.
841 """
843 committer_email: str
844 """
845 Committer email.
847 """
849 committer_datetime: datetime
850 """
851 Committer time.
853 """
855 title: str
856 """
857 Commit title, i.e. first line of the message.
859 """
861 body: str
862 """
863 Commit body, i.e. the rest of the message.
865 """
867 orig_ref: str | None = None
868 """
869 If commit was parsed from a user input, this field will contain
870 original input. I.e. if a user enters ``HEAD`` and it gets resolved
871 into a commit, `orig_ref` will contain string ``"HEAD"``.
873 See also :class:`CommitParser`.
875 """
877 @property
878 def short_hash(self):
879 """
880 First seven characters of the commit hash.
882 """
884 return self.hash[:7]
886 def __str__(self):
887 if self.orig_ref:
888 return self.orig_ref
889 else:
890 return self.short_hash
893@dataclass(kw_only=True, slots=True)
894class CommitTrailers:
895 """
896 Commit trailers.
898 """
900 hash: str
901 """
902 Commit hash.
904 """
906 trailers: list[tuple[str, str]]
907 """
908 Key-value pairs for commit trailers.
910 """
913class Modification(enum.Enum):
914 """
915 For changed file or submodule, what modification was applied to it.
917 """
919 UNMODIFIED = "."
920 """
921 File wasn't changed.
923 """
925 MODIFIED = "M"
926 """
927 File was changed.
929 """
931 SUBMODULE_MODIFIED = "m"
932 """
933 Contents of submodule were modified.
935 """
937 TYPE_CHANGED = "T"
938 """
939 File type changed.
941 """
943 ADDED = "A"
944 """
945 File was created.
947 """
949 DELETED = "D"
950 """
951 File was deleted.
953 """
955 RENAMED = "R"
956 """
957 File was renamed (and possibly changed).
959 """
961 COPIED = "C"
962 """
963 File was copied (and possibly changed).
965 """
967 UPDATED = "U"
968 """
969 File was updated but unmerged.
971 """
973 UNTRACKED = "?"
974 """
975 File is untracked, i.e. not yet staged or committed.
977 """
979 IGNORED = "!"
980 """
981 File is in ``.gitignore``.
983 """
986@dataclass(kw_only=True, slots=True)
987class PathStatus:
988 """
989 Status of a changed path.
991 """
993 path: pathlib.Path
994 """
995 Path of the file.
997 """
1000@dataclass(kw_only=True, slots=True)
1001class FileStatus(PathStatus):
1002 """
1003 Status of a changed file.
1005 """
1007 path_from: pathlib.Path | None
1008 """
1009 If file was moved, contains path where it was moved from.
1011 """
1013 staged: Modification
1014 """
1015 File modification in the index (staged).
1017 """
1019 tree: Modification
1020 """
1021 File modification in the tree (unstaged).
1023 """
1026@dataclass(kw_only=True, slots=True)
1027class SubmoduleStatus(FileStatus):
1028 """
1029 Status of a submodule.
1031 """
1033 commit_changed: bool
1034 """
1035 The submodule has a different HEAD than recorded in the index.
1037 """
1039 has_tracked_changes: bool
1040 """
1041 Tracked files were changed in the submodule.
1043 """
1045 has_untracked_changes: bool
1046 """
1047 Untracked files were changed in the submodule.
1049 """
1052@dataclass(kw_only=True, slots=True)
1053class UnmergedFileStatus(PathStatus):
1054 """
1055 Status of an unmerged file.
1057 """
1059 us: Modification
1060 """
1061 File modification that has happened at the head.
1063 """
1065 them: Modification
1066 """
1067 File modification that has happened at the merge head.
1069 """
1072@dataclass(kw_only=True, slots=True)
1073class UnmergedSubmoduleStatus(UnmergedFileStatus):
1074 """
1075 Status of an unmerged submodule.
1077 """
1079 commit_changed: bool
1080 """
1081 The submodule has a different HEAD than recorded in the index.
1083 """
1085 has_tracked_changes: bool
1086 """
1087 Tracked files were changed in the submodule.
1089 """
1091 has_untracked_changes: bool
1092 """
1093 Untracked files were changed in the submodule.
1095 """
1098@dataclass(kw_only=True, slots=True)
1099class Status:
1100 """
1101 Status of a working copy.
1103 """
1105 commit: str | None
1106 """
1107 Current commit hash. Can be absent if current branch is orphaned and doesn't have
1108 any commits yet.
1110 """
1112 branch: str | None = None
1113 """
1114 Name of the current branch.
1116 """
1118 upstream: str | None = None
1119 """
1120 Name of the upstream branch.
1122 """
1124 ahead: int | None = None
1125 """
1126 Number of commits the branch is ahead of upstream.
1128 """
1130 behind: int | None = None
1131 """
1132 Number of commits the branch is behind of upstream.
1134 """
1136 changes: list[PathStatus] = dataclasses.field(default_factory=list)
1137 """
1138 List of changed files, both tracked and untracked.
1140 See details about change representation in `git status`__ manual.
1142 __ https://git-scm.com/docs/git-status#_output
1144 """
1146 cherry_pick_head: str | None = None
1147 """
1148 Position of the ``CHERRY_PICK_HEAD``.
1150 If this field is not :data:`None`, cherry pick is in progress.
1152 """
1154 merge_head: str | None = None
1155 """
1156 Position of the ``MERGE_HEAD``.
1158 If this field is not :data:`None`, merge is in progress.
1160 """
1162 rebase_head: str | None = None
1163 """
1164 Position of the ``REBASE_HEAD``.
1166 If this field is not :data:`None`, rebase is in progress.
1168 """
1170 revert_head: str | None = None
1171 """
1172 Position of the ``REVERT_HEAD``.
1174 If this field is not :data:`None`, revert is in progress.
1176 """
1178 def has_staged_changes(self) -> bool:
1179 """
1180 Return :data:`True` if there are unstaged changes in this repository.
1182 """
1184 return next(self.get_staged_changes(), None) is not None
1186 def get_staged_changes(self) -> _t.Iterator[PathStatus]:
1187 return (
1188 change
1189 for change in self.changes
1190 if isinstance(change, FileStatus)
1191 and change.staged
1192 not in [
1193 Modification.UNMODIFIED,
1194 Modification.IGNORED,
1195 Modification.UNTRACKED,
1196 ]
1197 )
1199 def has_unstaged_changes(self) -> bool:
1200 """
1201 Return :data:`True` if there are unstaged changes in this repository.
1203 """
1205 return next(self.get_unstaged_changes(), None) is not None
1207 def get_unstaged_changes(self) -> _t.Iterator[PathStatus]:
1208 return (
1209 change
1210 for change in self.changes
1211 if isinstance(change, UnmergedFileStatus)
1212 or (
1213 isinstance(change, FileStatus)
1214 and change.tree
1215 not in [
1216 Modification.UNMODIFIED,
1217 Modification.IGNORED,
1218 ]
1219 )
1220 )
1223class RefCompleterMode(enum.Enum):
1224 """
1225 Specifies operation modes for :class:`RefCompleter`.
1227 """
1229 BRANCH = "b"
1230 """
1231 Completes branches.
1233 """
1235 REMOTE = "r"
1236 """
1237 Completes remote branches.
1239 """
1241 TAG = "t"
1242 """
1243 Completes tags.
1245 """
1247 HEAD = "h"
1248 """
1249 Completes ``HEAD`` and ``ORIG_HEAD``.
1251 """
1254class RefCompleter(yuio.complete.Completer):
1255 """
1256 Completes git refs.
1258 :param repo:
1259 source of completions. If not given, this completer will try to use current
1260 directory as a repo root, and fail silently if it's not a repo.
1261 :param modes:
1262 which objects to complete.
1264 """
1266 def __init__(self, repo: Repo | None, modes: set[RefCompleterMode] | None = None):
1267 super().__init__()
1269 self._repo: Repo | None | _t.Literal[False] = repo
1270 self._modes = modes or {
1271 RefCompleterMode.BRANCH,
1272 RefCompleterMode.TAG,
1273 RefCompleterMode.HEAD,
1274 }
1276 def _process(self, collector: yuio.complete.CompletionCollector, /):
1277 if self._repo is None:
1278 try:
1279 self._repo = Repo(pathlib.Path.cwd())
1280 except (GitError, OSError):
1281 self._repo = False
1282 if not self._repo:
1283 return
1284 try:
1285 if RefCompleterMode.HEAD in self._modes:
1286 collector.add_group()
1287 git_dir = self._repo.git_dir
1288 for head in ["HEAD", "ORIG_HEAD"]:
1289 if (git_dir / head).exists():
1290 collector.add(head)
1291 if RefCompleterMode.BRANCH in self._modes:
1292 collector.add_group()
1293 for branch in self._repo.branches():
1294 collector.add(branch, comment="branch")
1295 if RefCompleterMode.REMOTE in self._modes:
1296 collector.add_group()
1297 for remote in self._repo.remotes():
1298 collector.add(remote, comment="remote")
1299 if RefCompleterMode.TAG in self._modes:
1300 collector.add_group()
1301 for tag in self._repo.tags():
1302 collector.add(tag, comment="tag")
1303 except GitError:
1304 pass
1306 def _get_completion_model(
1307 self, *, is_many: bool = False
1308 ) -> yuio.complete._OptionSerializer.Model:
1309 return yuio.complete._OptionSerializer.Git(
1310 "".join(sorted(list(m.value for m in self._modes)))
1311 )
1314def CommitParser(*, repo: Repo) -> yuio.parse.Parser[Commit]:
1315 """
1316 A parser for git refs (commits, tags, branches, and so on).
1318 This parser validates that the given ref exists in the given repository,
1319 parses it and returns a commit data associated with this ref.
1321 If you need a simple string without additional validation,
1322 use :class:`RefParser`.
1324 :param repo:
1325 initialized repository is required to ensure that commit is valid.
1327 """
1329 def map(value: str, /) -> Commit:
1330 commit = repo.show(value)
1331 if commit is None:
1332 raise yuio.parse.ParsingError("invalid git ref `%s`", value)
1333 return commit
1335 def rev(value: Commit | object) -> str:
1336 if isinstance(value, Commit):
1337 return str(value)
1338 else:
1339 raise TypeError(
1340 f"parser Commit can't handle value of type {_tx.type_repr(type(value))}"
1341 )
1343 return yuio.parse.WithMeta(
1344 yuio.parse.Map(yuio.parse.Str(), map, rev),
1345 desc="<commit>",
1346 completer=RefCompleter(repo),
1347 )
1350T = _t.TypeVar("T")
1353class _RefParserImpl(yuio.parse.Str, _t.Generic[T]):
1354 @functools.cached_property
1355 def _description(self):
1356 return "<" + self.__class__.__name__.removesuffix("Parser").lower() + ">"
1358 def describe(self) -> str | None:
1359 return self._description
1361 def describe_or_def(self) -> str:
1362 return self._description
1364 def describe_many(self) -> str | tuple[str, ...]:
1365 return self._description
1368class RefParser(_RefParserImpl[Ref]):
1369 """
1370 A parser that provides autocompletion for git refs, but doesn't verify
1371 anything else.
1373 """
1375 def completer(self) -> yuio.complete.Completer:
1376 return RefCompleter(None)
1379class TagParser(_RefParserImpl[Tag]):
1380 """
1381 A parser that provides autocompletion for git tag, but doesn't verify
1382 anything else.
1384 """
1386 def completer(self) -> yuio.complete.Completer:
1387 return RefCompleter(None, {RefCompleterMode.TAG})
1390class BranchParser(_RefParserImpl[Branch]):
1391 """
1392 A parser that provides autocompletion for git branches, but doesn't verify
1393 anything else.
1395 """
1397 def completer(self) -> yuio.complete.Completer:
1398 return RefCompleter(None, {RefCompleterMode.BRANCH})
1401class RemoteParser(_RefParserImpl[Remote]):
1402 """
1403 A parser that provides autocompletion for git remotes, but doesn't verify
1404 anything else.
1406 """
1408 def completer(self) -> yuio.complete.Completer:
1409 return RefCompleter(None, {RefCompleterMode.REMOTE})
1412yuio.parse.register_type_hint_conversion(
1413 lambda ty, origin, args: RefParser() if ty is Ref else None
1414)
1416yuio.parse.register_type_hint_conversion(
1417 lambda ty, origin, args: TagParser() if ty is Tag else None
1418)
1420yuio.parse.register_type_hint_conversion(
1421 lambda ty, origin, args: BranchParser() if ty is Branch else None
1422)
1424yuio.parse.register_type_hint_conversion(
1425 lambda ty, origin, args: RemoteParser() if ty is Remote else None
1426)