Coverage for src/hatch_ci/scm.py: 90%

1# see https://pypi.org/project/setuptools-github

2# copy of setuptools_github.scm

3 from __future__ import annotations

4

5 import dataclasses as dc

6 import io

7 import re

8 import subprocess

9 from pathlib import Path

10 from typing import Any, List, Union

11

12 from typing_extensions import TypeAlias

13

14 ListOfArgs: TypeAlias = Union[str, Path, List[Union[str, Path]]]

15

16

17 def to_list_of_paths(paths: ListOfArgs) -> list[Path]:

18 return [Path(s) for s in ([paths] if isinstance(paths, (str, Path)) else paths)]

19

20

21 def indent(txt: str, pre: str = " " * 2) -> str:

22 "simple text indentation"

23

24 from textwrap import dedent

25

26 txt = dedent(txt)

27 if txt.endswith("\n"):

28 last_eol = "\n"

29 txt = txt[:-1]

30 else:

31 last_eol = ""

32

33 result = pre + txt.replace("\n", "\n" + pre) + last_eol

34 return result if result.strip() else result.strip()

35

36

37 def shorthand(txt: str) -> str:

38 tag = "refs/heads/"

39 return txt[len(tag) :] if txt.startswith(tag) else txt

40

41

42 class NA:

43 pass

44

45

46 class GitError(Exception):

47 pass

48

49

50 class InvalidGitRepoError(GitError):

51 pass

52

53

54@dc.dataclass

55 class GitRepoBranches:

56 local: list[str]

57 remote: list[str]

58

59

60@dc.dataclass

61 class GitRepoHead:

62 @dc.dataclass

63 class GitRepoHeadHex:

64 hex: str # noqa: A003

65

66 name: str

67 target: GitRepoHeadHex

68

69 @property

70 def shorthand(self):

71 return shorthand(self.name)

72

73

74 class 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()

79

80 def __call__(self, cmd: ListOfArgs) -> str:

81 cmds = cmd if isinstance(cmd, list) else [cmd]

82

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

95

96 def __truediv__(self, other):

97 return (self.workdir / other).absolute()

98

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

108

109 buf = io.StringIO()

110 print("\n".join([line.rstrip() for line in lines.split("\n")]), file=buf)

111 return buf.getvalue()

112

113

114 class GitRepo(GitRepoBase):

115 @property

116 def config(self):

117 @dc.dataclass

118 class X:

119 repo: GitRepo

120

121 def __getitem__(self, item: str):

122 return self.repo(["config", item]).strip()

123

124 def __setitem__(self, item: str, value: Any):

125 self.repo(["config", item, str(value)])

126

127 def __contains__(self, item: str):

128 return item in self.repo(

129 [

130 "config",

131 "--list",

132 "--name-only",

133 ]

134 ).split("\n")

135

136 return X(self)

137

138 def revert(self, paths: ListOfArgs | None = None):

139 sources = to_list_of_paths(paths or self.workdir)

140 self(["checkout", *sources])

141

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

150

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

182

183 def dirty(self) -> bool:

184 return bool(self.status(untracked_files="no"))

185

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

194

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

204

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

218

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 ]

226

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

234

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

240

241 self(

242 [

243 "clone",

244 *(["--branch", branch] if branch else []),

245 self.workdir.absolute(),

246 workdir.absolute(),

247 ],

248 )

249

250 repo = self.__class__(workdir=workdir)

251 repo(["config", "user.name", self(["config", "user.name"])])

252 repo(["config", "user.email", self(["config", "user.email"])])

253

254 return repo

255

256

257 def 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