Coverage for yuio / git.py: 97%
424 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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 ``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 ``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 import _typing as _t
162from yuio.util import dedent as _dedent
164__all__ = [
165 "Branch",
166 "BranchParser",
167 "Commit",
168 "CommitParser",
169 "CommitTrailers",
170 "FileStatus",
171 "GitError",
172 "GitExecError",
173 "GitUnavailableError",
174 "Modification",
175 "NotARepositoryError",
176 "PathStatus",
177 "Ref",
178 "RefCompleter",
179 "RefCompleterMode",
180 "RefParser",
181 "Remote",
182 "RemoteParser",
183 "Repo",
184 "Status",
185 "SubmoduleStatus",
186 "Tag",
187 "TagParser",
188 "UnmergedFileStatus",
189 "UnmergedSubmoduleStatus",
190]
192_logger = logging.getLogger(__name__)
195class GitError(Exception):
196 """
197 Raised when interaction with git fails.
199 """
202class GitExecError(GitError, yuio.exec.ExecError):
203 """
204 Raised when git returns a non-zero exit code.
206 """
209class GitUnavailableError(GitError, FileNotFoundError):
210 """
211 Raised when git executable can't be found.
213 """
216class NotARepositoryError(GitError, FileNotFoundError):
217 """
218 Raised when given path is not in git repository.
220 """
223Ref = _t.NewType("Ref", str)
224"""
225A special kind of string that contains a git object reference.
227Ref is not guaranteed to be valid; this type is used in type hints
228to make use of the :class:`RefParser`.
230"""
232Tag = _t.NewType("Tag", str)
233"""
234A special kind of string that contains a tag name.
236Ref is not guaranteed to be valid; this type is used in type hints
237to make use of the :class:`TagParser`.
239"""
241Branch = _t.NewType("Branch", str)
242"""
243A special kind of string that contains a branch name.
245Ref is not guaranteed to be valid; this type is used in type hints
246to make use of the :class:`BranchParser`.
248"""
250Remote = _t.NewType("Remote", str)
251"""
252A special kind of string that contains a remote branch name.
254Ref is not guaranteed to be valid; this type is used in type hints
255to make use of the :class:`RemoteParser`.
257"""
260# See https://git-scm.com/docs/git-log#_pretty_formats
261# for explanation of these incantations.
262_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-"
263_LOG_TRAILERS_FMT = "%H%n%w(0,1,1)%(trailers:only=true)%w(0,0)%n-"
264_LOG_TRAILER_KEY_RE = re.compile(r"^(?P<key>\S+):\s")
267class Repo:
268 """
269 A class that allows interactions with a git repository.
271 :param path:
272 path to the repo root dir.
273 :param env:
274 environment variables for the git executable.
275 :raises:
276 constructor of this class may raise :class:`GitError` if git isn't available
277 or if the given part is not inside of a git repository.
279 """
281 def __init__(
282 self,
283 path: pathlib.Path | str,
284 /,
285 env: dict[str, str] | None = None,
286 ):
287 self.__path = pathlib.Path(path)
288 self.__env = env
289 self.__git_is_available = None
290 self.__is_repo = None
292 try:
293 version = self.git("--version")
294 except GitExecError:
295 raise GitUnavailableError("git executable is not available")
297 _logger.debug("%s", version.decode(errors="replace").strip())
299 try:
300 self.git("rev-parse", "--is-inside-work-tree")
301 except GitExecError:
302 raise NotARepositoryError(f"{self.__path} is not a git repository")
304 @property
305 def path(self) -> pathlib.Path:
306 """
307 Path to the repo, as was passed to the constructor.
309 """
311 return self.__path
313 @functools.cached_property
314 def root(self) -> pathlib.Path:
315 """
316 The root directory of the repo.
318 """
320 return pathlib.Path(
321 self.git("rev-parse", "--path-format=absolute", "--show-toplevel")
322 .decode()
323 .strip()
324 ).resolve()
326 @functools.cached_property
327 def git_dir(self) -> pathlib.Path:
328 """
329 Get path to the ``.git`` directory of the repo.
331 """
333 return pathlib.Path(
334 self.git("rev-parse", "--path-format=absolute", "--git-dir")
335 .decode()
336 .strip()
337 ).resolve()
339 @_t.overload
340 def git(self, *args: str | pathlib.Path) -> bytes: ...
342 @_t.overload
343 def git(self, *args: str | pathlib.Path, capture_io: _t.Literal[False]) -> None: ...
345 @_t.overload
346 def git(self, *args: str | pathlib.Path, capture_io: bool) -> bytes | None: ...
348 def git(self, *args: str | pathlib.Path, capture_io: bool = True):
349 """
350 Call git and return its stdout.
352 :param args:
353 arguments for the ``git`` command.
354 :param capture_io:
355 If set to :data:`False`, command's stderr and stdout are not captured.
356 :returns:
357 output of the git command.
358 :raises:
359 :class:`GitError`, :class:`OSError`.
361 """
363 try:
364 return yuio.exec.exec(
365 "git",
366 *args,
367 cwd=self.__path,
368 env=self.__env,
369 capture_io=capture_io,
370 text=False,
371 )
372 except yuio.exec.ExecError as e:
373 raise GitExecError(e.returncode, e.cmd, e.output, e.stderr)
374 except FileNotFoundError:
375 raise GitUnavailableError("git executable not found")
377 def status(
378 self, /, include_ignored: bool = False, include_submodules: bool = True
379 ) -> Status:
380 """
381 Query the current repository status.
383 :param include_ignored:
384 include ignored status in the list of changes. Disable by default.
385 :param include_submodules:
386 include status of submodules in the list of changes. Enabled by default.
387 :returns:
388 current repository status.
389 :raises:
390 :class:`GitError`, :class:`OSError`.
392 """
394 text = self.git(
395 "status",
396 "--porcelain=v2",
397 "-z",
398 "--ahead-behind",
399 "--branch",
400 "--renames",
401 "--untracked-files=normal",
402 "--ignore-submodules=" + ("none" if include_submodules else "all"),
403 "--ignored=" + ("matching" if include_ignored else "no"),
404 )
405 lines = iter(text.split(b"\0"))
407 status = Status(commit=None)
409 for line_b in lines:
410 line = line_b.decode()
411 if line.startswith("# branch.oid"):
412 if line[13:] != "(initial)":
413 status.commit = line[13:]
414 elif line.startswith("# branch.head"):
415 if line[14:] != "(detached)":
416 status.branch = line[14:]
417 elif line.startswith("# branch.upstream"):
418 status.upstream = line[18:]
419 elif line.startswith("# branch.ab"):
420 match = re.match(r"^\+(\d+) -(\d+)$", line[12:])
421 assert match is not None
422 status.ahead = int(match.group(1))
423 status.behind = int(match.group(2))
424 elif line.startswith("1"):
425 match = re.match(
426 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){5}(?P<path>.*)$",
427 line[2:],
428 )
429 assert match is not None
430 sub = match.group("sub")
431 if sub[0] == "S":
432 path_status = SubmoduleStatus(
433 path=pathlib.Path(match.group("path")),
434 path_from=None,
435 staged=Modification(match.group("X")),
436 tree=Modification(match.group("Y")),
437 commit_changed=sub[1] != ".",
438 has_tracked_changes=sub[2] != ".",
439 has_untracked_changes=sub[3] != ".",
440 )
441 else:
442 path_status = FileStatus(
443 path=pathlib.Path(match.group("path")),
444 path_from=None,
445 staged=Modification(match.group("X")),
446 tree=Modification(match.group("Y")),
447 )
448 status.changes.append(path_status)
449 elif line.startswith("2"):
450 match = re.match(
451 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){6}(?P<path>.*)$",
452 line[2:],
453 )
454 assert match is not None
455 path_from = pathlib.Path(next(lines).decode())
456 sub = match.group("sub")
457 if sub[0] == "S":
458 path_status = SubmoduleStatus(
459 path=pathlib.Path(match.group("path")),
460 path_from=path_from,
461 staged=Modification(match.group("X")),
462 tree=Modification(match.group("Y")),
463 commit_changed=sub[1] != ".",
464 has_tracked_changes=sub[2] != ".",
465 has_untracked_changes=sub[3] != ".",
466 )
467 else:
468 path_status = FileStatus(
469 path=pathlib.Path(match.group("path")),
470 path_from=path_from,
471 staged=Modification(match.group("X")),
472 tree=Modification(match.group("Y")),
473 )
474 status.changes.append(path_status)
475 elif line.startswith("u"):
476 match = re.match(
477 r"^(?P<X>.)(?P<Y>.) (?P<sub>.{4}) (?:[^ ]+ ){7}(?P<path>.*)$",
478 line[2:],
479 )
480 assert match is not None
481 sub = match.group("sub")
482 if sub[0] == "S":
483 path_status = UnmergedSubmoduleStatus(
484 path=pathlib.Path(match.group("path")),
485 us=Modification(match.group("X")),
486 them=Modification(match.group("Y")),
487 commit_changed=sub[1] != ".",
488 has_tracked_changes=sub[2] != ".",
489 has_untracked_changes=sub[3] != ".",
490 )
491 else:
492 path_status = UnmergedFileStatus(
493 path=pathlib.Path(match.group("path")),
494 us=Modification(match.group("X")),
495 them=Modification(match.group("Y")),
496 )
497 status.changes.append(path_status)
498 elif line.startswith("?"):
499 status.changes.append(
500 FileStatus(
501 path=pathlib.Path(line[2:]),
502 path_from=None,
503 staged=Modification.UNTRACKED,
504 tree=Modification.UNTRACKED,
505 )
506 )
507 elif line.startswith("!"):
508 status.changes.append(
509 FileStatus(
510 path=pathlib.Path(line[2:]),
511 path_from=None,
512 staged=Modification.IGNORED,
513 tree=Modification.IGNORED,
514 )
515 )
517 try:
518 status.cherry_pick_head = (
519 self.git("rev-parse", "--verify", "CHERRY_PICK_HEAD").decode().strip()
520 )
521 except GitError:
522 pass
523 try:
524 status.merge_head = (
525 self.git("rev-parse", "--verify", "MERGE_HEAD").decode().strip()
526 )
527 except GitError:
528 pass
529 try:
530 status.rebase_head = (
531 self.git("rev-parse", "--verify", "REBASE_HEAD").decode().strip()
532 )
533 except GitError:
534 pass
535 try:
536 status.revert_head = (
537 self.git("rev-parse", "--verify", "REVERT_HEAD").decode().strip()
538 )
539 except GitError:
540 pass
542 return status
544 def print_status(self):
545 """
546 Run ``git status`` and show its output to the user.
548 """
550 self.git("status", capture_io=False)
552 def log(self, *refs: str, max_entries: int | None = None) -> list[Commit]:
553 """
554 Query the log for given git objects.
556 :param refs:
557 git references that will be passed to ``git log``.
558 :param max_entries:
559 maximum number of returned references.
560 :returns:
561 list of found commits.
562 :raises:
563 :class:`GitError`, :class:`OSError`.
565 """
567 args = [
568 f"--pretty=format:{_LOG_FMT}",
569 "--decorate-refs=refs/tags",
570 "--decorate=short",
571 ]
573 if max_entries is not None:
574 args += ["-n", str(max_entries)]
576 args += list(refs)
578 text = self.git("log", *args)
579 lines = iter(text.decode().split("\n"))
581 commits = []
583 while commit := self.__parse_single_log_entry(lines):
584 commits.append(commit)
586 return commits
588 def trailers(
589 self, *refs: str, max_entries: int | None = None
590 ) -> list[CommitTrailers]:
591 """
592 Query trailer lines for given git objects.
594 Trailers are lines at the end of a commit message formatted as ``key: value``
595 pairs. See `git-interpret-trailers`__ for further description.
597 __ https://git-scm.com/docs/git-interpret-trailers
599 :param refs:
600 git references that will be passed to ``git log``.
601 :param max_entries:
602 maximum number of checked commits.
604 .. warning::
606 This option limits number of checked commits, not the number
607 of trailers.
608 :returns:
609 list of found commits and their trailers.
610 :raises:
611 :class:`GitError`, :class:`OSError`.
613 """
615 args = [f"--pretty=format:{_LOG_TRAILERS_FMT}"]
617 if max_entries is not None:
618 args += ["-n", str(max_entries)]
620 args += list(refs)
622 text = self.git("log", *args)
623 lines = iter(text.decode().split("\n"))
625 trailers = []
627 while commit := self.__parse_single_trailer_entry(lines):
628 trailers.append(commit)
630 return trailers
632 def show(self, ref: str, /) -> Commit | None:
633 """
634 Query information for the given git object.
636 Return :data:`None` if object is not found.
638 :param ref:
639 git reference that will be passed to ``git log``.
640 :returns:
641 found commit or :data:`None`.
642 :raises:
643 :class:`OSError`.
645 """
647 try:
648 self.git("rev-parse", "--verify", ref)
649 except GitError:
650 return None
652 log = self.log(ref, max_entries=1)
653 if not log:
654 return None
655 else:
656 commit = log[0]
657 commit.orig_ref = ref
658 return commit
660 @staticmethod
661 def __parse_single_log_entry(lines) -> Commit | None:
662 try:
663 commit = next(lines)
664 author = next(lines)
665 author_email = next(lines)
666 author_datetime = datetime.fromisoformat(next(lines).replace("Z", "+00:00"))
667 committer = next(lines)
668 committer_email = next(lines)
669 committer_datetime = datetime.fromisoformat(
670 next(lines).replace("Z", "+00:00")
671 )
672 tags = next(lines).split()
673 title = next(lines)
674 body = ""
676 while True:
677 line = next(lines)
678 if not line or line.startswith(" "):
679 body += line[1:] + "\n"
680 else:
681 break
683 body = body.strip("\n")
684 if body:
685 body += "\n"
687 return Commit(
688 hash=commit,
689 tags=tags,
690 author=author,
691 author_email=author_email,
692 author_datetime=author_datetime,
693 committer=committer,
694 committer_email=committer_email,
695 committer_datetime=committer_datetime,
696 title=title,
697 body=body,
698 )
699 except StopIteration:
700 return None
702 @staticmethod
703 def __parse_single_trailer_entry(
704 lines,
705 ) -> CommitTrailers | None:
706 try:
707 commit = next(lines)
708 trailers = []
709 current_key = None
710 current_value = ""
712 while True:
713 line = next(lines)
714 if not line or line.startswith(" "):
715 line = line[1:] + "\n"
716 if match := _LOG_TRAILER_KEY_RE.match(line):
717 if current_key:
718 current_value = _dedent(current_value)
719 trailers.append((current_key, current_value))
720 current_key = match.group("key")
721 current_value = line[match.end() :]
722 else:
723 current_value += line
724 else:
725 break
726 if current_key:
727 current_value = _dedent(current_value)
728 trailers.append((current_key, current_value))
730 return CommitTrailers(hash=commit, trailers=trailers)
731 except StopIteration:
732 return None
734 def tags(self) -> list[str]:
735 """
736 List all tags in this repository.
738 :returns:
739 list of strings representing tags, without ``refs/tags`` prefix,
740 sorted lexicographically as strings.
741 :raises:
742 :class:`GitError`, :class:`OSError`.
744 """
746 return (
747 self.git("for-each-ref", "--format=%(refname:short)", "refs/tags")
748 .decode()
749 .splitlines()
750 )
752 def branches(self) -> list[str]:
753 """
754 List all branches in this repository.
756 :returns:
757 list of strings representing branch names, without ``refs/heads`` prefix,
758 sorted lexicographically as strings.
759 :raises:
760 :class:`GitError`, :class:`OSError`.
762 """
764 return (
765 self.git("for-each-ref", "--format=%(refname:short)", "refs/heads")
766 .decode()
767 .splitlines()
768 )
770 def remotes(self) -> list[str]:
771 """
772 List all remote branches in this repository.
774 :returns:
775 list of strings representing remote branches, without
776 ``refs/remotes`` prefix, sorted lexicographically as strings.
777 :raises:
778 :class:`GitError`, :class:`OSError`.
780 """
782 return [
783 remote
784 for remote in self.git(
785 "for-each-ref", "--format=%(refname:short)", "refs/remotes"
786 )
787 .decode()
788 .splitlines()
789 if "/" in remote
790 ]
793@dataclass(kw_only=True, slots=True)
794class Commit:
795 """
796 Commit description.
798 """
800 hash: str
801 """
802 Commit hash.
804 """
806 tags: list[str]
807 """
808 Tags attached to this commit.
810 """
812 author: str
813 """
814 Author name.
816 """
818 author_email: str
819 """
820 Author email.
822 """
824 author_datetime: datetime
825 """
826 Author time.
828 """
830 committer: str
831 """
832 Committer name.
834 """
836 committer_email: str
837 """
838 Committer email.
840 """
842 committer_datetime: datetime
843 """
844 Committer time.
846 """
848 title: str
849 """
850 Commit title, i.e. first line of the message.
852 """
854 body: str
855 """
856 Commit body, i.e. the rest of the message.
858 """
860 orig_ref: str | None = None
861 """
862 If commit was parsed from a user input, this field will contain
863 original input. I.e. if a user enters ``HEAD`` and it gets resolved
864 into a commit, `orig_ref` will contain string ``"HEAD"``.
866 See also :class:`CommitParser`.
868 """
870 @property
871 def short_hash(self):
872 """
873 First seven characters of the commit hash.
875 """
877 return self.hash[:7]
879 def __str__(self):
880 if self.orig_ref:
881 return self.orig_ref
882 else:
883 return self.short_hash
886@dataclass(kw_only=True, slots=True)
887class CommitTrailers:
888 """
889 Commit trailers.
891 """
893 hash: str
894 """
895 Commit hash.
897 """
899 trailers: list[tuple[str, str]]
900 """
901 Key-value pairs for commit trailers.
903 """
906class Modification(enum.Enum):
907 """
908 For changed file or submodule, what modification was applied to it.
910 """
912 UNMODIFIED = "."
913 """
914 File wasn't changed.
916 """
918 MODIFIED = "M"
919 """
920 File was changed.
922 """
924 SUBMODULE_MODIFIED = "m"
925 """
926 Contents of submodule were modified.
928 """
930 TYPE_CHANGED = "T"
931 """
932 File type changed.
934 """
936 ADDED = "A"
937 """
938 File was created.
940 """
942 DELETED = "D"
943 """
944 File was deleted.
946 """
948 RENAMED = "R"
949 """
950 File was renamed (and possibly changed).
952 """
954 COPIED = "C"
955 """
956 File was copied (and possibly changed).
958 """
960 UPDATED = "U"
961 """
962 File was updated but unmerged.
964 """
966 UNTRACKED = "?"
967 """
968 File is untracked, i.e. not yet staged or committed.
970 """
972 IGNORED = "!"
973 """
974 File is in ``.gitignore``.
976 """
979@dataclass(kw_only=True, slots=True)
980class PathStatus:
981 """
982 Status of a changed path.
984 """
986 path: pathlib.Path
987 """
988 Path of the file.
990 """
993@dataclass(kw_only=True, slots=True)
994class FileStatus(PathStatus):
995 """
996 Status of a changed file.
998 """
1000 path_from: pathlib.Path | None
1001 """
1002 If file was moved, contains path where it was moved from.
1004 """
1006 staged: Modification
1007 """
1008 File modification in the index (staged).
1010 """
1012 tree: Modification
1013 """
1014 File modification in the tree (unstaged).
1016 """
1019@dataclass(kw_only=True, slots=True)
1020class SubmoduleStatus(FileStatus):
1021 """
1022 Status of a submodule.
1024 """
1026 commit_changed: bool
1027 """
1028 The submodule has a different HEAD than recorded in the index.
1030 """
1032 has_tracked_changes: bool
1033 """
1034 Tracked files were changed in the submodule.
1036 """
1038 has_untracked_changes: bool
1039 """
1040 Untracked files were changed in the submodule.
1042 """
1045@dataclass(kw_only=True, slots=True)
1046class UnmergedFileStatus(PathStatus):
1047 """
1048 Status of an unmerged file.
1050 """
1052 us: Modification
1053 """
1054 File modification that has happened at the head.
1056 """
1058 them: Modification
1059 """
1060 File modification that has happened at the merge head.
1062 """
1065@dataclass(kw_only=True, slots=True)
1066class UnmergedSubmoduleStatus(UnmergedFileStatus):
1067 """
1068 Status of an unmerged submodule.
1070 """
1072 commit_changed: bool
1073 """
1074 The submodule has a different HEAD than recorded in the index.
1076 """
1078 has_tracked_changes: bool
1079 """
1080 Tracked files were changed in the submodule.
1082 """
1084 has_untracked_changes: bool
1085 """
1086 Untracked files were changed in the submodule.
1088 """
1091@dataclass(kw_only=True, slots=True)
1092class Status:
1093 """
1094 Status of a working copy.
1096 """
1098 commit: str | None
1099 """
1100 Current commit hash. Can be absent if current branch is orphaned and doesn't have
1101 any commits yet.
1103 """
1105 branch: str | None = None
1106 """
1107 Name of the current branch.
1109 """
1111 upstream: str | None = None
1112 """
1113 Name of the upstream branch.
1115 """
1117 ahead: int | None = None
1118 """
1119 Number of commits the branch is ahead of upstream.
1121 """
1123 behind: int | None = None
1124 """
1125 Number of commits the branch is behind of upstream.
1127 """
1129 changes: list[PathStatus] = dataclasses.field(default_factory=list)
1130 """
1131 List of changed files, both tracked and untracked.
1133 See details about change representation in `git status`__ manual.
1135 __ https://git-scm.com/docs/git-status#_output
1137 """
1139 cherry_pick_head: str | None = None
1140 """
1141 Position of the ``CHERRY_PICK_HEAD``.
1143 If this field is not :data:`None`, cherry pick is in progress.
1145 """
1147 merge_head: str | None = None
1148 """
1149 Position of the ``MERGE_HEAD``.
1151 If this field is not :data:`None`, merge is in progress.
1153 """
1155 rebase_head: str | None = None
1156 """
1157 Position of the ``REBASE_HEAD``.
1159 If this field is not :data:`None`, rebase is in progress.
1161 """
1163 revert_head: str | None = None
1164 """
1165 Position of the ``REVERT_HEAD``.
1167 If this field is not :data:`None`, revert is in progress.
1169 """
1171 def has_staged_changes(self) -> bool:
1172 """
1173 Return :data:`True` if there are unstaged changes in this repository.
1175 """
1177 return next(self.get_staged_changes(), None) is not None
1179 def get_staged_changes(self) -> _t.Iterator[PathStatus]:
1180 return (
1181 change
1182 for change in self.changes
1183 if isinstance(change, FileStatus)
1184 and change.staged
1185 not in [
1186 Modification.UNMODIFIED,
1187 Modification.IGNORED,
1188 Modification.UNTRACKED,
1189 ]
1190 )
1192 def has_unstaged_changes(self) -> bool:
1193 """
1194 Return :data:`True` if there are unstaged changes in this repository.
1196 """
1198 return next(self.get_unstaged_changes(), None) is not None
1200 def get_unstaged_changes(self) -> _t.Iterator[PathStatus]:
1201 return (
1202 change
1203 for change in self.changes
1204 if isinstance(change, UnmergedFileStatus)
1205 or (
1206 isinstance(change, FileStatus)
1207 and change.tree
1208 not in [
1209 Modification.UNMODIFIED,
1210 Modification.IGNORED,
1211 ]
1212 )
1213 )
1216class RefCompleterMode(enum.Enum):
1217 """
1218 Specifies operation modes for :class:`RefCompleter`.
1220 """
1222 BRANCH = "b"
1223 """
1224 Completes branches.
1226 """
1228 REMOTE = "r"
1229 """
1230 Completes remote branches.
1232 """
1234 TAG = "t"
1235 """
1236 Completes tags.
1238 """
1240 HEAD = "h"
1241 """
1242 Completes ``HEAD`` and ``ORIG_HEAD``.
1244 """
1247class RefCompleter(yuio.complete.Completer):
1248 """
1249 Completes git refs.
1251 :param repo:
1252 source of completions. If not given, this completer will try to use current
1253 directory as a repo root, and fail silently if it's not a repo.
1254 :param modes:
1255 which objects to complete.
1257 """
1259 def __init__(self, repo: Repo | None, modes: set[RefCompleterMode] | None = None):
1260 super().__init__()
1262 self._repo: Repo | None | _t.Literal[False] = repo
1263 self._modes = modes or {
1264 RefCompleterMode.BRANCH,
1265 RefCompleterMode.TAG,
1266 RefCompleterMode.HEAD,
1267 }
1269 def _process(self, collector: yuio.complete.CompletionCollector, /):
1270 if self._repo is None:
1271 try:
1272 self._repo = Repo(pathlib.Path.cwd())
1273 except (GitError, OSError):
1274 self._repo = False
1275 if not self._repo:
1276 return
1277 try:
1278 if RefCompleterMode.HEAD in self._modes:
1279 collector.add_group()
1280 git_dir = self._repo.git_dir
1281 for head in ["HEAD", "ORIG_HEAD"]:
1282 if (git_dir / head).exists():
1283 collector.add(head)
1284 if RefCompleterMode.BRANCH in self._modes:
1285 collector.add_group()
1286 for branch in self._repo.branches():
1287 collector.add(branch, comment="branch")
1288 if RefCompleterMode.REMOTE in self._modes:
1289 collector.add_group()
1290 for remote in self._repo.remotes():
1291 collector.add(remote, comment="remote")
1292 if RefCompleterMode.TAG in self._modes:
1293 collector.add_group()
1294 for tag in self._repo.tags():
1295 collector.add(tag, comment="tag")
1296 except GitError:
1297 pass
1299 def _get_completion_model(
1300 self, *, is_many: bool = False
1301 ) -> yuio.complete._CompleterSerializer.Model:
1302 return yuio.complete._CompleterSerializer.Git(
1303 {
1304 yuio.complete._CompleterSerializer.Git.Mode(mode.value)
1305 for mode in self._modes
1306 }
1307 )
1310def CommitParser(*, repo: Repo) -> yuio.parse.Parser[Commit]:
1311 """
1312 A parser for git refs (commits, tags, branches, and so on).
1314 This parser validates that the given ref exists in the given repository,
1315 parses it and returns a commit data associated with this ref.
1317 If you need a simple string without additional validation,
1318 use :class:`RefParser`.
1320 :param repo:
1321 initialized repository is required to ensure that commit is valid.
1323 """
1325 def map(value: str, /) -> Commit:
1326 commit = repo.show(value)
1327 if commit is None:
1328 raise yuio.parse.ParsingError("invalid git ref `%s`", value)
1329 return commit
1331 def rev(value: Commit | object) -> str:
1332 if isinstance(value, Commit):
1333 return str(value)
1334 else:
1335 raise TypeError(
1336 f"parser Commit can't handle value of type {_t.type_repr(type(value))}"
1337 )
1339 return yuio.parse.WithMeta(
1340 yuio.parse.Map(yuio.parse.Str(), map, rev),
1341 desc="<commit>",
1342 completer=RefCompleter(repo),
1343 )
1346T = _t.TypeVar("T")
1349class _RefParserImpl(yuio.parse.Str, _t.Generic[T]):
1350 @functools.cached_property
1351 def _description(self):
1352 return "<" + self.__class__.__name__.removesuffix("Parser").lower() + ">"
1354 def describe(self) -> str | None:
1355 return self._description
1357 def describe_or_def(self) -> str:
1358 return self._description
1360 def describe_many(self) -> str | tuple[str, ...]:
1361 return self._description
1364class RefParser(_RefParserImpl[Ref]):
1365 """
1366 A parser that provides autocompletion for git refs, but doesn't verify
1367 anything else.
1369 """
1371 def completer(self) -> yuio.complete.Completer:
1372 return RefCompleter(None)
1375class TagParser(_RefParserImpl[Tag]):
1376 """
1377 A parser that provides autocompletion for git tag, but doesn't verify
1378 anything else.
1380 """
1382 def completer(self) -> yuio.complete.Completer:
1383 return RefCompleter(None, {RefCompleterMode.TAG})
1386class BranchParser(_RefParserImpl[Branch]):
1387 """
1388 A parser that provides autocompletion for git branches, but doesn't verify
1389 anything else.
1391 """
1393 def completer(self) -> yuio.complete.Completer:
1394 return RefCompleter(None, {RefCompleterMode.BRANCH})
1397class RemoteParser(_RefParserImpl[Remote]):
1398 """
1399 A parser that provides autocompletion for git remotes, but doesn't verify
1400 anything else.
1402 """
1404 def completer(self) -> yuio.complete.Completer:
1405 return RefCompleter(None, {RefCompleterMode.REMOTE})
1408yuio.parse.register_type_hint_conversion(
1409 lambda ty, origin, args: RefParser() if ty is Ref else None
1410)
1412yuio.parse.register_type_hint_conversion(
1413 lambda ty, origin, args: TagParser() if ty is Tag else None
1414)
1416yuio.parse.register_type_hint_conversion(
1417 lambda ty, origin, args: BranchParser() if ty is Branch else None
1418)
1420yuio.parse.register_type_hint_conversion(
1421 lambda ty, origin, args: RemoteParser() if ty is Remote else None
1422)