Coverage for src/hatch_ci/scm.py: 79%
160 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-01 20:04 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-01 20:04 +0000
1# see https://pypi.org/project/setuptools-github
2# copy of setuptools_github.scm
3from __future__ import annotations
5import dataclasses as dc
6import io
7import re
8import subprocess
9from pathlib import Path
10from typing import Any, List, Union
12from typing_extensions import TypeAlias
14ListOfArgs: TypeAlias = Union[str, Path, List[Union[str, Path]]]
17def to_list_of_paths(paths: ListOfArgs) -> list[Path]:
18 return [Path(s) for s in ([paths] if isinstance(paths, (str, Path)) else paths)]
21def indent(txt: str, pre: str = " " * 2) -> str:
22 "simple text indentation"
24 from textwrap import dedent
26 txt = dedent(txt)
27 if txt.endswith("\n"):
28 last_eol = "\n"
29 txt = txt[:-1]
30 else:
31 last_eol = ""
33 result = pre + txt.replace("\n", "\n" + pre) + last_eol
34 return result if result.strip() else result.strip()
37def shorthand(txt: str) -> str:
38 tag = "refs/heads/"
39 return txt[len(tag) :] if txt.startswith(tag) else txt
42class NA:
43 pass
46class GitError(Exception):
47 pass
50class InvalidGitRepoError(GitError):
51 pass
54@dc.dataclass
55class GitRepoBranches:
56 local: list[str]
57 remote: list[str]
60@dc.dataclass
61class GitRepoHead:
62 @dc.dataclass
63 class GitRepoHeadHex:
64 hex: str # noqa: A003
66 name: str
67 target: GitRepoHeadHex
69 @property
70 def shorthand(self):
71 return shorthand(self.name)
74class GitRepoBase:
75 def __init__(self, workdir: Path | str, exe: str = "git", gitdir: Path | str = ""):
76 self.workdir = Path(workdir).absolute()
77 self.exe = exe
78 self.gitdir = Path(gitdir or (self.workdir / ".git")).absolute()
80 def __call__(self, cmd: ListOfArgs) -> str:
81 cmds = cmd if isinstance(cmd, list) else [cmd]
83 arguments = [self.exe]
84 if cmds[0] != "clone":
85 arguments.extend(
86 [
87 "--work-tree",
88 str(self.workdir),
89 "--git-dir",
90 str(self.gitdir),
91 ]
92 )
93 arguments.extend(str(c) for c in cmds)
94 return subprocess.check_output(arguments, encoding="utf-8") # noqa: S603
96 def __truediv__(self, other):
97 return (self.workdir / other).absolute()
99 def dumps(self, mask: bool = False) -> str:
100 lines = f"REPO: {self.workdir}"
101 lines += "\n [status]\n" + indent(self(["status"]))
102 branches = self(["branch", "-avv"])
103 if mask:
104 branches = re.sub(r"(..\w\s+)\w{7}(\s+.*)", r"\1ABCDEFG\2", branches)
105 lines += "\n [branch]\n" + indent(branches)
106 lines += "\n [tags]\n" + indent(self(["tag", "-l"]))
107 lines += "\n [remote]\n" + indent(self(["remote", "-v"]))
109 buf = io.StringIO()
110 print("\n".join([line.rstrip() for line in lines.split("\n")]), file=buf)
111 return buf.getvalue()
114class GitRepo(GitRepoBase):
115 @property
116 def config(self):
117 @dc.dataclass
118 class X:
119 repo: GitRepo
121 def __getitem__(self, item: str):
122 return self.repo(["config", item]).strip()
124 def __setitem__(self, item: str, value: Any):
125 self.repo(["config", item, str(value)])
127 def __contains__(self, item: str):
128 return item in self.repo(
129 [
130 "config",
131 "--list",
132 "--name-only",
133 ]
134 ).split("\n")
136 return X(self)
138 def revert(self, paths: ListOfArgs | None = None):
139 sources = to_list_of_paths(paths or self.workdir)
140 self(["checkout", *sources])
142 @property
143 def head(self):
144 name = self(["symbolic-ref", "HEAD"]).strip()
145 try:
146 txt = self(["rev-parse", name]).strip()
147 except subprocess.CalledProcessError as exc:
148 raise GitError(f"no branch '{name}'") from exc
149 return GitRepoHead(name=name, target=GitRepoHead.GitRepoHeadHex(txt))
151 def status(
152 self,
153 untracked_files: str = "all",
154 ignored: bool = False,
155 ) -> dict[str, int]:
156 # to update the mapping:
157 # pygit2.Repository(self.workdir).status()
158 mapper = {
159 "??": 128 if untracked_files == "all" else None,
160 " D": 512,
161 "D ": 4,
162 " M": 256,
163 "A ": 1,
164 }
165 result: dict[str, int] = {}
166 try:
167 txt = self(["status", "--porcelain"])
168 except subprocess.CalledProcessError as exc:
169 raise GitError("invalid repo") from exc
170 for line in txt.split("\n"):
171 if not line.strip(): 171 ↛ 173line 171 didn't jump to line 173, because the condition on line 171 was never false
172 continue
173 tag, filename = line[:2], line[3:]
174 if tag not in mapper:
175 raise GitError(f"cannot map git status for '{tag}'")
176 value = mapper[tag]
177 if value:
178 result[filename] = (
179 (result[filename] | value) if filename in result else value
180 )
181 return result
183 def commit(
184 self,
185 paths: ListOfArgs,
186 message: str,
187 ) -> None:
188 all_paths = to_list_of_paths(paths)
189 self(["add", *all_paths])
190 self(["commit", "-m", message, *all_paths])
192 def branch(self, name: str | None = None, origin: str = "master") -> str:
193 if not name:
194 name = self.head.name or ""
195 return name[11:] if name.startswith("refs/heads/") else name
196 if not (origin or origin is None): 196 ↛ 197line 196 didn't jump to line 197, because the condition on line 196 was never true
197 raise RuntimeError(f"invalid {origin=}")
198 old = self.branch()
199 self(["checkout", "-b", name, "--track", origin])
200 return old[11:] if old.startswith("refs/heads/") else old
202 @property
203 def branches(self) -> GitRepoBranches:
204 result = GitRepoBranches([], [])
205 for line in self(["branch", "-a", "--format", "%(refname)"]).split("\n"):
206 if not line.strip():
207 continue
208 if line.startswith("refs/heads/"):
209 result.local.append(line[11:])
210 elif line.startswith("refs/remotes/"): 210 ↛ 213line 210 didn't jump to line 213, because the condition on line 210 was never false
211 result.remote.append(line[13:])
212 else:
213 raise RuntimeError(f"invalid branch {line}")
214 return result
216 @property
217 def references(self) -> list[str]:
218 return [
219 f"refs/tags/{line.strip()}"
220 for line in self(["tag", "-l"]).split("\n")
221 if line.strip()
222 ]
224 def clone(
225 self,
226 dest: str | Path,
227 force: bool = False,
228 branch: str | None = None,
229 ) -> GitRepo:
230 from shutil import rmtree
232 workdir = Path(dest).absolute()
233 if force: 233 ↛ 234line 233 didn't jump to line 234, because the condition on line 233 was never true
234 rmtree(workdir, ignore_errors=True)
235 if workdir.exists(): 235 ↛ 236line 235 didn't jump to line 236, because the condition on line 235 was never true
236 raise ValueError(f"target directory present {workdir}")
238 self(
239 [
240 "clone",
241 *(["--branch", branch] if branch else []),
242 self.workdir.absolute(),
243 workdir.absolute(),
244 ],
245 )
247 repo = self.__class__(workdir=workdir)
248 repo(["config", "user.name", self(["config", "user.name"])])
249 repo(["config", "user.email", self(["config", "user.email"])])
251 return repo
254def lookup(path: Path) -> GitRepo | None:
255 cur = path
256 found = False
257 while not found: 257 ↛ 263line 257 didn't jump to line 263, because the condition on line 257 was never false
258 if (cur / ".git").exists():
259 return GitRepo(cur)
260 if str(cur) == cur.root: 260 ↛ 261line 260 didn't jump to line 261, because the condition on line 260 was never true
261 break
262 cur = cur.parent
263 return None