Coverage for yuio / git.py: 97%

424 statements  

« 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 

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 ``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 ``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 import _typing as _t 

162from yuio.util import dedent as _dedent 

163 

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] 

191 

192_logger = logging.getLogger(__name__) 

193 

194 

195class GitError(Exception): 

196 """ 

197 Raised when interaction with git fails. 

198 

199 """ 

200 

201 

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

203 """ 

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

205 

206 """ 

207 

208 

209class GitUnavailableError(GitError, FileNotFoundError): 

210 """ 

211 Raised when git executable can't be found. 

212 

213 """ 

214 

215 

216class NotARepositoryError(GitError, FileNotFoundError): 

217 """ 

218 Raised when given path is not in git repository. 

219 

220 """ 

221 

222 

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

224""" 

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

226 

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

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

229 

230""" 

231 

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

233""" 

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

235 

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

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

238 

239""" 

240 

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

242""" 

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

244 

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

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

247 

248""" 

249 

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

251""" 

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

253 

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

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

256 

257""" 

258 

259 

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

265 

266 

267class Repo: 

268 """ 

269 A class that allows interactions with a git repository. 

270 

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. 

278 

279 """ 

280 

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 

291 

292 try: 

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

294 except GitExecError: 

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

296 

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

298 

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

303 

304 @property 

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

306 """ 

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

308 

309 """ 

310 

311 return self.__path 

312 

313 @functools.cached_property 

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

315 """ 

316 The root directory of the repo. 

317 

318 """ 

319 

320 return pathlib.Path( 

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

322 .decode() 

323 .strip() 

324 ).resolve() 

325 

326 @functools.cached_property 

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

328 """ 

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

330 

331 """ 

332 

333 return pathlib.Path( 

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

335 .decode() 

336 .strip() 

337 ).resolve() 

338 

339 @_t.overload 

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

341 

342 @_t.overload 

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

344 

345 @_t.overload 

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

347 

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

349 """ 

350 Call git and return its stdout. 

351 

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

360 

361 """ 

362 

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

376 

377 def status( 

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

379 ) -> Status: 

380 """ 

381 Query the current repository status. 

382 

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

391 

392 """ 

393 

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

406 

407 status = Status(commit=None) 

408 

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 ) 

516 

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 

541 

542 return status 

543 

544 def print_status(self): 

545 """ 

546 Run ``git status`` and show its output to the user. 

547 

548 """ 

549 

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

551 

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

553 """ 

554 Query the log for given git objects. 

555 

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

564 

565 """ 

566 

567 args = [ 

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

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

570 "--decorate=short", 

571 ] 

572 

573 if max_entries is not None: 

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

575 

576 args += list(refs) 

577 

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

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

580 

581 commits = [] 

582 

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

584 commits.append(commit) 

585 

586 return commits 

587 

588 def trailers( 

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

590 ) -> list[CommitTrailers]: 

591 """ 

592 Query trailer lines for given git objects. 

593 

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

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

596 

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

598 

599 :param refs: 

600 git references that will be passed to ``git log``. 

601 :param max_entries: 

602 maximum number of checked commits. 

603 

604 .. warning:: 

605 

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

612 

613 """ 

614 

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

616 

617 if max_entries is not None: 

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

619 

620 args += list(refs) 

621 

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

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

624 

625 trailers = [] 

626 

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

628 trailers.append(commit) 

629 

630 return trailers 

631 

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

633 """ 

634 Query information for the given git object. 

635 

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

637 

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

644 

645 """ 

646 

647 try: 

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

649 except GitError: 

650 return None 

651 

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 

659 

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

675 

676 while True: 

677 line = next(lines) 

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

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

680 else: 

681 break 

682 

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

684 if body: 

685 body += "\n" 

686 

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 

701 

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

711 

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

729 

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

731 except StopIteration: 

732 return None 

733 

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

735 """ 

736 List all tags in this repository. 

737 

738 :returns: 

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

740 sorted lexicographically as strings. 

741 :raises: 

742 :class:`GitError`, :class:`OSError`. 

743 

744 """ 

745 

746 return ( 

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

748 .decode() 

749 .splitlines() 

750 ) 

751 

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

753 """ 

754 List all branches in this repository. 

755 

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

761 

762 """ 

763 

764 return ( 

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

766 .decode() 

767 .splitlines() 

768 ) 

769 

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

771 """ 

772 List all remote branches in this repository. 

773 

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

779 

780 """ 

781 

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 ] 

791 

792 

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

794class Commit: 

795 """ 

796 Commit description. 

797 

798 """ 

799 

800 hash: str 

801 """ 

802 Commit hash. 

803 

804 """ 

805 

806 tags: list[str] 

807 """ 

808 Tags attached to this commit. 

809 

810 """ 

811 

812 author: str 

813 """ 

814 Author name. 

815 

816 """ 

817 

818 author_email: str 

819 """ 

820 Author email. 

821 

822 """ 

823 

824 author_datetime: datetime 

825 """ 

826 Author time. 

827 

828 """ 

829 

830 committer: str 

831 """ 

832 Committer name. 

833 

834 """ 

835 

836 committer_email: str 

837 """ 

838 Committer email. 

839 

840 """ 

841 

842 committer_datetime: datetime 

843 """ 

844 Committer time. 

845 

846 """ 

847 

848 title: str 

849 """ 

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

851 

852 """ 

853 

854 body: str 

855 """ 

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

857 

858 """ 

859 

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

865 

866 See also :class:`CommitParser`. 

867 

868 """ 

869 

870 @property 

871 def short_hash(self): 

872 """ 

873 First seven characters of the commit hash. 

874 

875 """ 

876 

877 return self.hash[:7] 

878 

879 def __str__(self): 

880 if self.orig_ref: 

881 return self.orig_ref 

882 else: 

883 return self.short_hash 

884 

885 

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

887class CommitTrailers: 

888 """ 

889 Commit trailers. 

890 

891 """ 

892 

893 hash: str 

894 """ 

895 Commit hash. 

896 

897 """ 

898 

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

900 """ 

901 Key-value pairs for commit trailers. 

902 

903 """ 

904 

905 

906class Modification(enum.Enum): 

907 """ 

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

909 

910 """ 

911 

912 UNMODIFIED = "." 

913 """ 

914 File wasn't changed. 

915 

916 """ 

917 

918 MODIFIED = "M" 

919 """ 

920 File was changed. 

921 

922 """ 

923 

924 SUBMODULE_MODIFIED = "m" 

925 """ 

926 Contents of submodule were modified. 

927 

928 """ 

929 

930 TYPE_CHANGED = "T" 

931 """ 

932 File type changed. 

933 

934 """ 

935 

936 ADDED = "A" 

937 """ 

938 File was created. 

939 

940 """ 

941 

942 DELETED = "D" 

943 """ 

944 File was deleted. 

945 

946 """ 

947 

948 RENAMED = "R" 

949 """ 

950 File was renamed (and possibly changed). 

951 

952 """ 

953 

954 COPIED = "C" 

955 """ 

956 File was copied (and possibly changed). 

957 

958 """ 

959 

960 UPDATED = "U" 

961 """ 

962 File was updated but unmerged. 

963 

964 """ 

965 

966 UNTRACKED = "?" 

967 """ 

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

969 

970 """ 

971 

972 IGNORED = "!" 

973 """ 

974 File is in ``.gitignore``. 

975 

976 """ 

977 

978 

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

980class PathStatus: 

981 """ 

982 Status of a changed path. 

983 

984 """ 

985 

986 path: pathlib.Path 

987 """ 

988 Path of the file. 

989 

990 """ 

991 

992 

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

994class FileStatus(PathStatus): 

995 """ 

996 Status of a changed file. 

997 

998 """ 

999 

1000 path_from: pathlib.Path | None 

1001 """ 

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

1003 

1004 """ 

1005 

1006 staged: Modification 

1007 """ 

1008 File modification in the index (staged). 

1009 

1010 """ 

1011 

1012 tree: Modification 

1013 """ 

1014 File modification in the tree (unstaged). 

1015 

1016 """ 

1017 

1018 

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

1020class SubmoduleStatus(FileStatus): 

1021 """ 

1022 Status of a submodule. 

1023 

1024 """ 

1025 

1026 commit_changed: bool 

1027 """ 

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

1029 

1030 """ 

1031 

1032 has_tracked_changes: bool 

1033 """ 

1034 Tracked files were changed in the submodule. 

1035 

1036 """ 

1037 

1038 has_untracked_changes: bool 

1039 """ 

1040 Untracked files were changed in the submodule. 

1041 

1042 """ 

1043 

1044 

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

1046class UnmergedFileStatus(PathStatus): 

1047 """ 

1048 Status of an unmerged file. 

1049 

1050 """ 

1051 

1052 us: Modification 

1053 """ 

1054 File modification that has happened at the head. 

1055 

1056 """ 

1057 

1058 them: Modification 

1059 """ 

1060 File modification that has happened at the merge head. 

1061 

1062 """ 

1063 

1064 

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

1066class UnmergedSubmoduleStatus(UnmergedFileStatus): 

1067 """ 

1068 Status of an unmerged submodule. 

1069 

1070 """ 

1071 

1072 commit_changed: bool 

1073 """ 

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

1075 

1076 """ 

1077 

1078 has_tracked_changes: bool 

1079 """ 

1080 Tracked files were changed in the submodule. 

1081 

1082 """ 

1083 

1084 has_untracked_changes: bool 

1085 """ 

1086 Untracked files were changed in the submodule. 

1087 

1088 """ 

1089 

1090 

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

1092class Status: 

1093 """ 

1094 Status of a working copy. 

1095 

1096 """ 

1097 

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. 

1102 

1103 """ 

1104 

1105 branch: str | None = None 

1106 """ 

1107 Name of the current branch. 

1108 

1109 """ 

1110 

1111 upstream: str | None = None 

1112 """ 

1113 Name of the upstream branch. 

1114 

1115 """ 

1116 

1117 ahead: int | None = None 

1118 """ 

1119 Number of commits the branch is ahead of upstream. 

1120 

1121 """ 

1122 

1123 behind: int | None = None 

1124 """ 

1125 Number of commits the branch is behind of upstream. 

1126 

1127 """ 

1128 

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

1130 """ 

1131 List of changed files, both tracked and untracked. 

1132 

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

1134 

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

1136 

1137 """ 

1138 

1139 cherry_pick_head: str | None = None 

1140 """ 

1141 Position of the ``CHERRY_PICK_HEAD``. 

1142 

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

1144 

1145 """ 

1146 

1147 merge_head: str | None = None 

1148 """ 

1149 Position of the ``MERGE_HEAD``. 

1150 

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

1152 

1153 """ 

1154 

1155 rebase_head: str | None = None 

1156 """ 

1157 Position of the ``REBASE_HEAD``. 

1158 

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

1160 

1161 """ 

1162 

1163 revert_head: str | None = None 

1164 """ 

1165 Position of the ``REVERT_HEAD``. 

1166 

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

1168 

1169 """ 

1170 

1171 def has_staged_changes(self) -> bool: 

1172 """ 

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

1174 

1175 """ 

1176 

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

1178 

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 ) 

1191 

1192 def has_unstaged_changes(self) -> bool: 

1193 """ 

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

1195 

1196 """ 

1197 

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

1199 

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 ) 

1214 

1215 

1216class RefCompleterMode(enum.Enum): 

1217 """ 

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

1219 

1220 """ 

1221 

1222 BRANCH = "b" 

1223 """ 

1224 Completes branches. 

1225 

1226 """ 

1227 

1228 REMOTE = "r" 

1229 """ 

1230 Completes remote branches. 

1231 

1232 """ 

1233 

1234 TAG = "t" 

1235 """ 

1236 Completes tags. 

1237 

1238 """ 

1239 

1240 HEAD = "h" 

1241 """ 

1242 Completes ``HEAD`` and ``ORIG_HEAD``. 

1243 

1244 """ 

1245 

1246 

1247class RefCompleter(yuio.complete.Completer): 

1248 """ 

1249 Completes git refs. 

1250 

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. 

1256 

1257 """ 

1258 

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

1260 super().__init__() 

1261 

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

1263 self._modes = modes or { 

1264 RefCompleterMode.BRANCH, 

1265 RefCompleterMode.TAG, 

1266 RefCompleterMode.HEAD, 

1267 } 

1268 

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 

1298 

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 ) 

1308 

1309 

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

1311 """ 

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

1313 

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. 

1316 

1317 If you need a simple string without additional validation, 

1318 use :class:`RefParser`. 

1319 

1320 :param repo: 

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

1322 

1323 """ 

1324 

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 

1330 

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 ) 

1338 

1339 return yuio.parse.WithMeta( 

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

1341 desc="<commit>", 

1342 completer=RefCompleter(repo), 

1343 ) 

1344 

1345 

1346T = _t.TypeVar("T") 

1347 

1348 

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

1350 @functools.cached_property 

1351 def _description(self): 

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

1353 

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

1355 return self._description 

1356 

1357 def describe_or_def(self) -> str: 

1358 return self._description 

1359 

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

1361 return self._description 

1362 

1363 

1364class RefParser(_RefParserImpl[Ref]): 

1365 """ 

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

1367 anything else. 

1368 

1369 """ 

1370 

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

1372 return RefCompleter(None) 

1373 

1374 

1375class TagParser(_RefParserImpl[Tag]): 

1376 """ 

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

1378 anything else. 

1379 

1380 """ 

1381 

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

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

1384 

1385 

1386class BranchParser(_RefParserImpl[Branch]): 

1387 """ 

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

1389 anything else. 

1390 

1391 """ 

1392 

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

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

1395 

1396 

1397class RemoteParser(_RefParserImpl[Remote]): 

1398 """ 

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

1400 anything else. 

1401 

1402 """ 

1403 

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

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

1406 

1407 

1408yuio.parse.register_type_hint_conversion( 

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

1410) 

1411 

1412yuio.parse.register_type_hint_conversion( 

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

1414) 

1415 

1416yuio.parse.register_type_hint_conversion( 

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

1418) 

1419 

1420yuio.parse.register_type_hint_conversion( 

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

1422)