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

160 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-01 21:34 +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(): 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 

182 

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

191 

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 

201 

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 

215 

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 ] 

223 

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 

231 

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

237 

238 self( 

239 [ 

240 "clone", 

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

242 self.workdir.absolute(), 

243 workdir.absolute(), 

244 ], 

245 ) 

246 

247 repo = self.__class__(workdir=workdir) 

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

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

250 

251 return repo 

252 

253 

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