Coverage for yuio / git.py: 97%

426 statements  

« 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 

7 

8""" 

9This module provides basic functionality to interact with git. 

10It comes in handy when writing deployment scripts. 

11 

12 

13Interacting with a repository 

14----------------------------- 

15 

16All repository interactions are done through the :class:`Repo` class 

17and its methods. If an interaction fails, a :class:`GitError` is raised. 

18 

19.. autoclass:: Repo 

20 :members: 

21 

22.. autoclass:: GitError 

23 

24.. autoclass:: GitExecError 

25 

26.. autoclass:: GitUnavailableError 

27 

28.. autoclass:: NotARepositoryError 

29 

30 

31Status objects 

32-------------- 

33 

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. 

37 

38__ https://git-scm.com/docs/git-status#_output 

39 

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: 

43 

44.. raw:: html 

45 

46 <p> 

47 <pre class="mermaid"> 

48 --- 

49 config: 

50 class: 

51 hideEmptyMembersBox: true 

52 --- 

53 classDiagram 

54 

55 class PathStatus 

56 click PathStatus href "#yuio.git.PathStatus" "yuio.git.PathStatus" 

57 

58 class FileStatus 

59 click FileStatus href "#yuio.git.FileStatus" "yuio.git.FileStatus" 

60 PathStatus <|-- FileStatus 

61 

62 class SubmoduleStatus 

63 click SubmoduleStatus href "#yuio.git.SubmoduleStatus" "yuio.git.SubmoduleStatus" 

64 FileStatus <|-- SubmoduleStatus 

65 

66 class UnmergedFileStatus 

67 click UnmergedFileStatus href "#yuio.git.UnmergedFileStatus" "yuio.git.UnmergedFileStatus" 

68 PathStatus <|-- UnmergedFileStatus 

69 

70 class UnmergedSubmoduleStatus 

71 click UnmergedSubmoduleStatus href "#yuio.git.UnmergedSubmoduleStatus" "yuio.git.UnmergedSubmoduleStatus" 

72 UnmergedFileStatus <|-- UnmergedSubmoduleStatus 

73 </pre> 

74 </p> 

75 

76.. autoclass:: Status 

77 :members: 

78 

79.. autoclass:: PathStatus 

80 :members: 

81 

82.. autoclass:: FileStatus 

83 :members: 

84 

85.. autoclass:: SubmoduleStatus 

86 :members: 

87 

88.. autoclass:: UnmergedFileStatus 

89 :members: 

90 

91.. autoclass:: UnmergedSubmoduleStatus 

92 :members: 

93 

94.. autoclass:: Modification 

95 :members: 

96 

97 

98Commit objects 

99-------------- 

100 

101.. autoclass:: Commit 

102 :members: 

103 

104.. autoclass:: CommitTrailers 

105 :members: 

106 

107 

108Parsing git refs 

109---------------- 

110 

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: 

114 

115.. autoclass:: Ref 

116 

117.. autoclass:: Tag 

118 

119.. autoclass:: Branch 

120 

121.. autoclass:: Remote 

122 

123.. autoclass:: RefParser 

124 

125.. autoclass:: TagParser 

126 

127.. autoclass:: BranchParser 

128 

129.. autoclass:: RemoteParser 

130 

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`: 

133 

134.. autoclass:: CommitParser 

135 

136 

137Autocompleting git refs 

138----------------------- 

139 

140.. autoclass:: RefCompleter 

141 

142.. autoclass:: RefCompleterMode 

143 :members: 

144 

145""" 

146 

147from __future__ import annotations 

148 

149import dataclasses 

150import enum 

151import functools 

152import logging 

153import pathlib 

154import re 

155from dataclasses import dataclass 

156from datetime import datetime 

157 

158import yuio.complete 

159import yuio.exec 

160import yuio.parse 

161from yuio.util import dedent as _dedent 

162 

163import yuio._typing_ext as _tx 

164from typing import TYPE_CHECKING 

165 

166if TYPE_CHECKING: 

167 import typing_extensions as _t 

168else: 

169 from yuio import _typing as _t 

170 

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] 

198 

199_logger = logging.getLogger(__name__) 

200 

201 

202class GitError(Exception): 

203 """ 

204 Raised when interaction with git fails. 

205 

206 """ 

207 

208 

209class GitExecError(GitError, yuio.exec.ExecError): 

210 """ 

211 Raised when git returns a non-zero exit code. 

212 

213 """ 

214 

215 

216class GitUnavailableError(GitError, FileNotFoundError): 

217 """ 

218 Raised when git executable can't be found. 

219 

220 """ 

221 

222 

223class NotARepositoryError(GitError, FileNotFoundError): 

224 """ 

225 Raised when given path is not in git repository. 

226 

227 """ 

228 

229 

230Ref = _t.NewType("Ref", str) 

231""" 

232A special kind of string that contains a git object reference. 

233 

234Ref is not guaranteed to be valid; this type is used in type hints 

235to make use of the :class:`RefParser`. 

236 

237""" 

238 

239Tag = _t.NewType("Tag", str) 

240""" 

241A special kind of string that contains a tag name. 

242 

243Ref is not guaranteed to be valid; this type is used in type hints 

244to make use of the :class:`TagParser`. 

245 

246""" 

247 

248Branch = _t.NewType("Branch", str) 

249""" 

250A special kind of string that contains a branch name. 

251 

252Ref is not guaranteed to be valid; this type is used in type hints 

253to make use of the :class:`BranchParser`. 

254 

255""" 

256 

257Remote = _t.NewType("Remote", str) 

258""" 

259A special kind of string that contains a remote branch name. 

260 

261Ref is not guaranteed to be valid; this type is used in type hints 

262to make use of the :class:`RemoteParser`. 

263 

264""" 

265 

266 

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") 

272 

273 

274class Repo: 

275 """ 

276 A class that allows interactions with a git repository. 

277 

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. 

285 

286 """ 

287 

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 

298 

299 try: 

300 version = self.git("--version") 

301 except GitExecError: 

302 raise GitUnavailableError("git executable is not available") 

303 

304 _logger.debug("%s", version.decode(errors="replace").strip()) 

305 

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") 

310 

311 @property 

312 def path(self) -> pathlib.Path: 

313 """ 

314 Path to the repo, as was passed to the constructor. 

315 

316 """ 

317 

318 return self.__path 

319 

320 @functools.cached_property 

321 def root(self) -> pathlib.Path: 

322 """ 

323 The root directory of the repo. 

324 

325 """ 

326 

327 return pathlib.Path( 

328 self.git("rev-parse", "--path-format=absolute", "--show-toplevel") 

329 .decode() 

330 .strip() 

331 ).resolve() 

332 

333 @functools.cached_property 

334 def git_dir(self) -> pathlib.Path: 

335 """ 

336 Get path to the ``.git`` directory of the repo. 

337 

338 """ 

339 

340 return pathlib.Path( 

341 self.git("rev-parse", "--path-format=absolute", "--git-dir") 

342 .decode() 

343 .strip() 

344 ).resolve() 

345 

346 @_t.overload 

347 def git(self, *args: str | pathlib.Path) -> bytes: ... 

348 

349 @_t.overload 

350 def git(self, *args: str | pathlib.Path, capture_io: _t.Literal[False]) -> None: ... 

351 

352 @_t.overload 

353 def git(self, *args: str | pathlib.Path, capture_io: bool) -> bytes | None: ... 

354 

355 def git(self, *args: str | pathlib.Path, capture_io: bool = True): 

356 """ 

357 Call git and return its stdout. 

358 

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

367 

368 """ 

369 

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") 

383 

384 def status( 

385 self, /, include_ignored: bool = False, include_submodules: bool = True 

386 ) -> Status: 

387 """ 

388 Query the current repository status. 

389 

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

398 

399 """ 

400 

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")) 

413 

414 status = Status(commit=None) 

415 

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 ) 

523 

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 

548 

549 return status 

550 

551 def print_status(self): 

552 """ 

553 Run :flag:`git status` and show its output to the user. 

554 

555 """ 

556 

557 self.git("status", capture_io=False) 

558 

559 def log(self, *refs: str, max_entries: int | None = None) -> list[Commit]: 

560 """ 

561 Query the log for given git objects. 

562 

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

571 

572 """ 

573 

574 args = [ 

575 f"--pretty=format:{_LOG_FMT}", 

576 "--decorate-refs=refs/tags", 

577 "--decorate=short", 

578 ] 

579 

580 if max_entries is not None: 

581 args += ["-n", str(max_entries)] 

582 

583 args += list(refs) 

584 

585 text = self.git("log", *args) 

586 lines = iter(text.decode().split("\n")) 

587 

588 commits = [] 

589 

590 while commit := self.__parse_single_log_entry(lines): 

591 commits.append(commit) 

592 

593 return commits 

594 

595 def trailers( 

596 self, *refs: str, max_entries: int | None = None 

597 ) -> list[CommitTrailers]: 

598 """ 

599 Query trailer lines for given git objects. 

600 

601 Trailers are lines at the end of a commit message formatted as ``key: value`` 

602 pairs. See `git-interpret-trailers`__ for further description. 

603 

604 __ https://git-scm.com/docs/git-interpret-trailers 

605 

606 :param refs: 

607 git references that will be passed to :flag:`git log`. 

608 :param max_entries: 

609 maximum number of checked commits. 

610 

611 .. warning:: 

612 

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

619 

620 """ 

621 

622 args = [f"--pretty=format:{_LOG_TRAILERS_FMT}"] 

623 

624 if max_entries is not None: 

625 args += ["-n", str(max_entries)] 

626 

627 args += list(refs) 

628 

629 text = self.git("log", *args) 

630 lines = iter(text.decode().split("\n")) 

631 

632 trailers = [] 

633 

634 while commit := self.__parse_single_trailer_entry(lines): 

635 trailers.append(commit) 

636 

637 return trailers 

638 

639 def show(self, ref: str, /) -> Commit | None: 

640 """ 

641 Query information for the given git object. 

642 

643 Return :data:`None` if object is not found. 

644 

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

651 

652 """ 

653 

654 try: 

655 self.git("rev-parse", "--verify", ref) 

656 except GitError: 

657 return None 

658 

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 

666 

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 = "" 

682 

683 while True: 

684 line = next(lines) 

685 if not line or line.startswith(" "): 

686 body += line[1:] + "\n" 

687 else: 

688 break 

689 

690 body = body.strip("\n") 

691 if body: 

692 body += "\n" 

693 

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 

708 

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 = "" 

718 

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)) 

736 

737 return CommitTrailers(hash=commit, trailers=trailers) 

738 except StopIteration: 

739 return None 

740 

741 def tags(self) -> list[str]: 

742 """ 

743 List all tags in this repository. 

744 

745 :returns: 

746 list of strings representing tags, without ``refs/tags`` prefix, 

747 sorted lexicographically as strings. 

748 :raises: 

749 :class:`GitError`, :class:`OSError`. 

750 

751 """ 

752 

753 return ( 

754 self.git("for-each-ref", "--format=%(refname:short)", "refs/tags") 

755 .decode() 

756 .splitlines() 

757 ) 

758 

759 def branches(self) -> list[str]: 

760 """ 

761 List all branches in this repository. 

762 

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

768 

769 """ 

770 

771 return ( 

772 self.git("for-each-ref", "--format=%(refname:short)", "refs/heads") 

773 .decode() 

774 .splitlines() 

775 ) 

776 

777 def remotes(self) -> list[str]: 

778 """ 

779 List all remote branches in this repository. 

780 

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

786 

787 """ 

788 

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 ] 

798 

799 

800@dataclass(kw_only=True, slots=True) 

801class Commit: 

802 """ 

803 Commit description. 

804 

805 """ 

806 

807 hash: str 

808 """ 

809 Commit hash. 

810 

811 """ 

812 

813 tags: list[str] 

814 """ 

815 Tags attached to this commit. 

816 

817 """ 

818 

819 author: str 

820 """ 

821 Author name. 

822 

823 """ 

824 

825 author_email: str 

826 """ 

827 Author email. 

828 

829 """ 

830 

831 author_datetime: datetime 

832 """ 

833 Author time. 

834 

835 """ 

836 

837 committer: str 

838 """ 

839 Committer name. 

840 

841 """ 

842 

843 committer_email: str 

844 """ 

845 Committer email. 

846 

847 """ 

848 

849 committer_datetime: datetime 

850 """ 

851 Committer time. 

852 

853 """ 

854 

855 title: str 

856 """ 

857 Commit title, i.e. first line of the message. 

858 

859 """ 

860 

861 body: str 

862 """ 

863 Commit body, i.e. the rest of the message. 

864 

865 """ 

866 

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"``. 

872 

873 See also :class:`CommitParser`. 

874 

875 """ 

876 

877 @property 

878 def short_hash(self): 

879 """ 

880 First seven characters of the commit hash. 

881 

882 """ 

883 

884 return self.hash[:7] 

885 

886 def __str__(self): 

887 if self.orig_ref: 

888 return self.orig_ref 

889 else: 

890 return self.short_hash 

891 

892 

893@dataclass(kw_only=True, slots=True) 

894class CommitTrailers: 

895 """ 

896 Commit trailers. 

897 

898 """ 

899 

900 hash: str 

901 """ 

902 Commit hash. 

903 

904 """ 

905 

906 trailers: list[tuple[str, str]] 

907 """ 

908 Key-value pairs for commit trailers. 

909 

910 """ 

911 

912 

913class Modification(enum.Enum): 

914 """ 

915 For changed file or submodule, what modification was applied to it. 

916 

917 """ 

918 

919 UNMODIFIED = "." 

920 """ 

921 File wasn't changed. 

922 

923 """ 

924 

925 MODIFIED = "M" 

926 """ 

927 File was changed. 

928 

929 """ 

930 

931 SUBMODULE_MODIFIED = "m" 

932 """ 

933 Contents of submodule were modified. 

934 

935 """ 

936 

937 TYPE_CHANGED = "T" 

938 """ 

939 File type changed. 

940 

941 """ 

942 

943 ADDED = "A" 

944 """ 

945 File was created. 

946 

947 """ 

948 

949 DELETED = "D" 

950 """ 

951 File was deleted. 

952 

953 """ 

954 

955 RENAMED = "R" 

956 """ 

957 File was renamed (and possibly changed). 

958 

959 """ 

960 

961 COPIED = "C" 

962 """ 

963 File was copied (and possibly changed). 

964 

965 """ 

966 

967 UPDATED = "U" 

968 """ 

969 File was updated but unmerged. 

970 

971 """ 

972 

973 UNTRACKED = "?" 

974 """ 

975 File is untracked, i.e. not yet staged or committed. 

976 

977 """ 

978 

979 IGNORED = "!" 

980 """ 

981 File is in ``.gitignore``. 

982 

983 """ 

984 

985 

986@dataclass(kw_only=True, slots=True) 

987class PathStatus: 

988 """ 

989 Status of a changed path. 

990 

991 """ 

992 

993 path: pathlib.Path 

994 """ 

995 Path of the file. 

996 

997 """ 

998 

999 

1000@dataclass(kw_only=True, slots=True) 

1001class FileStatus(PathStatus): 

1002 """ 

1003 Status of a changed file. 

1004 

1005 """ 

1006 

1007 path_from: pathlib.Path | None 

1008 """ 

1009 If file was moved, contains path where it was moved from. 

1010 

1011 """ 

1012 

1013 staged: Modification 

1014 """ 

1015 File modification in the index (staged). 

1016 

1017 """ 

1018 

1019 tree: Modification 

1020 """ 

1021 File modification in the tree (unstaged). 

1022 

1023 """ 

1024 

1025 

1026@dataclass(kw_only=True, slots=True) 

1027class SubmoduleStatus(FileStatus): 

1028 """ 

1029 Status of a submodule. 

1030 

1031 """ 

1032 

1033 commit_changed: bool 

1034 """ 

1035 The submodule has a different HEAD than recorded in the index. 

1036 

1037 """ 

1038 

1039 has_tracked_changes: bool 

1040 """ 

1041 Tracked files were changed in the submodule. 

1042 

1043 """ 

1044 

1045 has_untracked_changes: bool 

1046 """ 

1047 Untracked files were changed in the submodule. 

1048 

1049 """ 

1050 

1051 

1052@dataclass(kw_only=True, slots=True) 

1053class UnmergedFileStatus(PathStatus): 

1054 """ 

1055 Status of an unmerged file. 

1056 

1057 """ 

1058 

1059 us: Modification 

1060 """ 

1061 File modification that has happened at the head. 

1062 

1063 """ 

1064 

1065 them: Modification 

1066 """ 

1067 File modification that has happened at the merge head. 

1068 

1069 """ 

1070 

1071 

1072@dataclass(kw_only=True, slots=True) 

1073class UnmergedSubmoduleStatus(UnmergedFileStatus): 

1074 """ 

1075 Status of an unmerged submodule. 

1076 

1077 """ 

1078 

1079 commit_changed: bool 

1080 """ 

1081 The submodule has a different HEAD than recorded in the index. 

1082 

1083 """ 

1084 

1085 has_tracked_changes: bool 

1086 """ 

1087 Tracked files were changed in the submodule. 

1088 

1089 """ 

1090 

1091 has_untracked_changes: bool 

1092 """ 

1093 Untracked files were changed in the submodule. 

1094 

1095 """ 

1096 

1097 

1098@dataclass(kw_only=True, slots=True) 

1099class Status: 

1100 """ 

1101 Status of a working copy. 

1102 

1103 """ 

1104 

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. 

1109 

1110 """ 

1111 

1112 branch: str | None = None 

1113 """ 

1114 Name of the current branch. 

1115 

1116 """ 

1117 

1118 upstream: str | None = None 

1119 """ 

1120 Name of the upstream branch. 

1121 

1122 """ 

1123 

1124 ahead: int | None = None 

1125 """ 

1126 Number of commits the branch is ahead of upstream. 

1127 

1128 """ 

1129 

1130 behind: int | None = None 

1131 """ 

1132 Number of commits the branch is behind of upstream. 

1133 

1134 """ 

1135 

1136 changes: list[PathStatus] = dataclasses.field(default_factory=list) 

1137 """ 

1138 List of changed files, both tracked and untracked. 

1139 

1140 See details about change representation in `git status`__ manual. 

1141 

1142 __ https://git-scm.com/docs/git-status#_output 

1143 

1144 """ 

1145 

1146 cherry_pick_head: str | None = None 

1147 """ 

1148 Position of the ``CHERRY_PICK_HEAD``. 

1149 

1150 If this field is not :data:`None`, cherry pick is in progress. 

1151 

1152 """ 

1153 

1154 merge_head: str | None = None 

1155 """ 

1156 Position of the ``MERGE_HEAD``. 

1157 

1158 If this field is not :data:`None`, merge is in progress. 

1159 

1160 """ 

1161 

1162 rebase_head: str | None = None 

1163 """ 

1164 Position of the ``REBASE_HEAD``. 

1165 

1166 If this field is not :data:`None`, rebase is in progress. 

1167 

1168 """ 

1169 

1170 revert_head: str | None = None 

1171 """ 

1172 Position of the ``REVERT_HEAD``. 

1173 

1174 If this field is not :data:`None`, revert is in progress. 

1175 

1176 """ 

1177 

1178 def has_staged_changes(self) -> bool: 

1179 """ 

1180 Return :data:`True` if there are unstaged changes in this repository. 

1181 

1182 """ 

1183 

1184 return next(self.get_staged_changes(), None) is not None 

1185 

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 ) 

1198 

1199 def has_unstaged_changes(self) -> bool: 

1200 """ 

1201 Return :data:`True` if there are unstaged changes in this repository. 

1202 

1203 """ 

1204 

1205 return next(self.get_unstaged_changes(), None) is not None 

1206 

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 ) 

1221 

1222 

1223class RefCompleterMode(enum.Enum): 

1224 """ 

1225 Specifies operation modes for :class:`RefCompleter`. 

1226 

1227 """ 

1228 

1229 BRANCH = "b" 

1230 """ 

1231 Completes branches. 

1232 

1233 """ 

1234 

1235 REMOTE = "r" 

1236 """ 

1237 Completes remote branches. 

1238 

1239 """ 

1240 

1241 TAG = "t" 

1242 """ 

1243 Completes tags. 

1244 

1245 """ 

1246 

1247 HEAD = "h" 

1248 """ 

1249 Completes ``HEAD`` and ``ORIG_HEAD``. 

1250 

1251 """ 

1252 

1253 

1254class RefCompleter(yuio.complete.Completer): 

1255 """ 

1256 Completes git refs. 

1257 

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. 

1263 

1264 """ 

1265 

1266 def __init__(self, repo: Repo | None, modes: set[RefCompleterMode] | None = None): 

1267 super().__init__() 

1268 

1269 self._repo: Repo | None | _t.Literal[False] = repo 

1270 self._modes = modes or { 

1271 RefCompleterMode.BRANCH, 

1272 RefCompleterMode.TAG, 

1273 RefCompleterMode.HEAD, 

1274 } 

1275 

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 

1305 

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 ) 

1312 

1313 

1314def CommitParser(*, repo: Repo) -> yuio.parse.Parser[Commit]: 

1315 """ 

1316 A parser for git refs (commits, tags, branches, and so on). 

1317 

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. 

1320 

1321 If you need a simple string without additional validation, 

1322 use :class:`RefParser`. 

1323 

1324 :param repo: 

1325 initialized repository is required to ensure that commit is valid. 

1326 

1327 """ 

1328 

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 

1334 

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 ) 

1342 

1343 return yuio.parse.WithMeta( 

1344 yuio.parse.Map(yuio.parse.Str(), map, rev), 

1345 desc="<commit>", 

1346 completer=RefCompleter(repo), 

1347 ) 

1348 

1349 

1350T = _t.TypeVar("T") 

1351 

1352 

1353class _RefParserImpl(yuio.parse.Str, _t.Generic[T]): 

1354 @functools.cached_property 

1355 def _description(self): 

1356 return "<" + self.__class__.__name__.removesuffix("Parser").lower() + ">" 

1357 

1358 def describe(self) -> str | None: 

1359 return self._description 

1360 

1361 def describe_or_def(self) -> str: 

1362 return self._description 

1363 

1364 def describe_many(self) -> str | tuple[str, ...]: 

1365 return self._description 

1366 

1367 

1368class RefParser(_RefParserImpl[Ref]): 

1369 """ 

1370 A parser that provides autocompletion for git refs, but doesn't verify 

1371 anything else. 

1372 

1373 """ 

1374 

1375 def completer(self) -> yuio.complete.Completer: 

1376 return RefCompleter(None) 

1377 

1378 

1379class TagParser(_RefParserImpl[Tag]): 

1380 """ 

1381 A parser that provides autocompletion for git tag, but doesn't verify 

1382 anything else. 

1383 

1384 """ 

1385 

1386 def completer(self) -> yuio.complete.Completer: 

1387 return RefCompleter(None, {RefCompleterMode.TAG}) 

1388 

1389 

1390class BranchParser(_RefParserImpl[Branch]): 

1391 """ 

1392 A parser that provides autocompletion for git branches, but doesn't verify 

1393 anything else. 

1394 

1395 """ 

1396 

1397 def completer(self) -> yuio.complete.Completer: 

1398 return RefCompleter(None, {RefCompleterMode.BRANCH}) 

1399 

1400 

1401class RemoteParser(_RefParserImpl[Remote]): 

1402 """ 

1403 A parser that provides autocompletion for git remotes, but doesn't verify 

1404 anything else. 

1405 

1406 """ 

1407 

1408 def completer(self) -> yuio.complete.Completer: 

1409 return RefCompleter(None, {RefCompleterMode.REMOTE}) 

1410 

1411 

1412yuio.parse.register_type_hint_conversion( 

1413 lambda ty, origin, args: RefParser() if ty is Ref else None 

1414) 

1415 

1416yuio.parse.register_type_hint_conversion( 

1417 lambda ty, origin, args: TagParser() if ty is Tag else None 

1418) 

1419 

1420yuio.parse.register_type_hint_conversion( 

1421 lambda ty, origin, args: BranchParser() if ty is Branch else None 

1422) 

1423 

1424yuio.parse.register_type_hint_conversion( 

1425 lambda ty, origin, args: RemoteParser() if ty is Remote else None 

1426)