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

162 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-07 20:20 +0000

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

2# copy of setuptools_github.scm 

3from __future__ import annotations 

4 

5import dataclasses as dc 

6import io 

7import re 

8import subprocess 

9from pathlib import Path 

10from typing import Any, List, Union 

11 

12from typing_extensions import TypeAlias 

13 

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

15 

16 

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

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

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

38 tag = "refs/heads/" 

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

40 

41 

42class NA: 

43 pass 

44 

45 

46class GitError(Exception): 

47 pass 

48 

49 

50class InvalidGitRepoError(GitError): 

51 pass 

52 

53 

54@dc.dataclass 

55class GitRepoBranches: 

56 local: list[str] 

57 remote: list[str] 

58 

59 

60@dc.dataclass 

61class 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 

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

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 

114class 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 

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