Coverage for src/hatch_ci/scm.py: 80%
158 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-28 04:31 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-28 04:31 +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 " M": 256,
162 "A ": 1,
163 }
164 result = {}
165 try:
166 txt = self(["status", "--porcelain"])
167 except subprocess.CalledProcessError as exc:
168 raise GitError("invalid repo") from exc
169 for line in txt.split("\n"):
170 if not line.strip(): 170 ↛ 172line 170 didn't jump to line 172, because the condition on line 170 was never false
171 continue
172 tag, filename = line[:2], line[3:]
173 value = mapper[tag]
174 if value:
175 result[filename] = value
176 return result
178 def commit(
179 self,
180 paths: ListOfArgs,
181 message: str,
182 ) -> None:
183 all_paths = to_list_of_paths(paths)
184 self(["add", *all_paths])
185 self(["commit", "-m", message, *all_paths])
187 def branch(self, name: str | None = None, origin: str = "master") -> str:
188 if not name:
189 name = self.head.name or ""
190 return name[11:] if name.startswith("refs/heads/") else name
191 if not (origin or origin is None): 191 ↛ 192line 191 didn't jump to line 192, because the condition on line 191 was never true
192 raise RuntimeError(f"invalid {origin=}")
193 old = self.branch()
194 self(["checkout", "-b", name, "--track", origin])
195 return old[11:] if old.startswith("refs/heads/") else old
197 @property
198 def branches(self) -> GitRepoBranches:
199 result = GitRepoBranches([], [])
200 for line in self(["branch", "-a", "--format", "%(refname)"]).split("\n"):
201 if not line.strip():
202 continue
203 if line.startswith("refs/heads/"):
204 result.local.append(line[11:])
205 elif line.startswith("refs/remotes/"): 205 ↛ 208line 205 didn't jump to line 208, because the condition on line 205 was never false
206 result.remote.append(line[13:])
207 else:
208 raise RuntimeError(f"invalid branch {line}")
209 return result
211 @property
212 def references(self) -> list[str]:
213 return [
214 f"refs/tags/{line.strip()}"
215 for line in self(["tag", "-l"]).split("\n")
216 if line.strip()
217 ]
219 def clone(
220 self,
221 dest: str | Path,
222 force: bool = False,
223 branch: str | None = None,
224 ) -> GitRepo:
225 from shutil import rmtree
227 workdir = Path(dest).absolute()
228 if force: 228 ↛ 229line 228 didn't jump to line 229, because the condition on line 228 was never true
229 rmtree(workdir, ignore_errors=True)
230 if workdir.exists(): 230 ↛ 231line 230 didn't jump to line 231, because the condition on line 230 was never true
231 raise ValueError(f"target directory present {workdir}")
233 self(
234 [
235 "clone",
236 *(["--branch", branch] if branch else []),
237 self.workdir.absolute(),
238 workdir.absolute(),
239 ],
240 )
242 repo = self.__class__(workdir=workdir)
243 repo(["config", "user.name", self(["config", "user.name"])])
244 repo(["config", "user.email", self(["config", "user.email"])])
246 return repo
249def lookup(path: Path) -> GitRepo | None:
250 cur = path
251 found = False
252 while not found: 252 ↛ 258line 252 didn't jump to line 258, because the condition on line 252 was never false
253 if (cur / ".git").exists():
254 return GitRepo(cur)
255 if str(cur) == cur.root: 255 ↛ 256line 255 didn't jump to line 256, because the condition on line 255 was never true
256 break
257 cur = cur.parent
258 return None