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

158 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-28 08:08 +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 " 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 

177 

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

186 

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 

196 

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 

210 

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 ] 

218 

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 

226 

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

232 

233 self( 

234 [ 

235 "clone", 

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

237 self.workdir.absolute(), 

238 workdir.absolute(), 

239 ], 

240 ) 

241 

242 repo = self.__class__(workdir=workdir) 

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

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

245 

246 return repo 

247 

248 

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