Coverage for src/hatch_ci/scm.py: 90%
162 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-08 08:07 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-08 08:07 +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():
172 continue
173 tag, filename = line[:2], line[3:]
174 if tag not in mapper: 174 ↛ 175line 174 didn't jump to line 175, because the condition on line 174 was never true
175 raise GitError(f"cannot map git status for '{tag}'")
176 value = mapper[tag]
177 if value: 177 ↛ 178line 177 didn't jump to line 178
178 result[filename] = (
179 (result[filename] | value) if filename in result else value
180 )
181 return result
183 def dirty(self) -> bool:
184 return bool(self.status(untracked_files="no"))
186 def commit(
187 self,
188 paths: ListOfArgs,
189 message: str,
190 ) -> None:
191 all_paths = to_list_of_paths(paths)
192 self(["add", *all_paths])
193 self(["commit", "-m", message, *all_paths])
195 def branch(self, name: str | None = None, origin: str = "master") -> str:
196 if not name:
197 name = self.head.name or ""
198 return name[11:] if name.startswith("refs/heads/") else name
199 if not (origin or origin is None): 199 ↛ 200line 199 didn't jump to line 200, because the condition on line 199 was never true
200 raise RuntimeError(f"invalid {origin=}")
201 old = self.branch()
202 self(["checkout", "-b", name, "--track", origin])
203 return old[11:] if old.startswith("refs/heads/") else old
205 @property
206 def branches(self) -> GitRepoBranches:
207 result = GitRepoBranches([], [])
208 for line in self(["branch", "-a", "--format", "%(refname)"]).split("\n"):
209 if not line.strip():
210 continue
211 if line.startswith("refs/heads/"):
212 result.local.append(line[11:])
213 elif line.startswith("refs/remotes/"): 213 ↛ 216line 213 didn't jump to line 216, because the condition on line 213 was never false
214 result.remote.append(line[13:])
215 else:
216 raise RuntimeError(f"invalid branch {line}")
217 return result
219 @property
220 def references(self) -> list[str]:
221 return [
222 f"refs/tags/{line.strip()}"
223 for line in self(["tag", "-l"]).split("\n")
224 if line.strip()
225 ]
227 def clone(
228 self,
229 dest: str | Path,
230 force: bool = False,
231 branch: str | None = None,
232 ) -> GitRepo:
233 from shutil import rmtree
235 workdir = Path(dest).absolute()
236 if force: 236 ↛ 237line 236 didn't jump to line 237, because the condition on line 236 was never true
237 rmtree(workdir, ignore_errors=True)
238 if workdir.exists(): 238 ↛ 239line 238 didn't jump to line 239, because the condition on line 238 was never true
239 raise ValueError(f"target directory present {workdir}")
241 self(
242 [
243 "clone",
244 *(["--branch", branch] if branch else []),
245 self.workdir.absolute(),
246 workdir.absolute(),
247 ],
248 )
250 repo = self.__class__(workdir=workdir)
251 repo(["config", "user.name", self(["config", "user.name"])])
252 repo(["config", "user.email", self(["config", "user.email"])])
254 return repo
257def lookup(path: Path) -> GitRepo | None:
258 cur = path
259 found = False
260 while not found: 260 ↛ 266line 260 didn't jump to line 266, because the condition on line 260 was never false
261 if (cur / ".git").exists():
262 return GitRepo(cur)
263 if str(cur) == cur.root: 263 ↛ 264line 263 didn't jump to line 264, because the condition on line 263 was never true
264 break
265 cur = cur.parent
266 return None