git-tide 0.1.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tide/gitutils.py ADDED
@@ -0,0 +1,340 @@
1
+ from __future__ import absolute_import, print_function, annotations
2
+
3
+ import fnmatch
4
+ import subprocess
5
+ import re
6
+ import os.path
7
+
8
+
9
+ from functools import lru_cache
10
+ from typing import overload, Literal, Iterable, Match, Pattern, Iterator
11
+
12
+
13
+ cache = lru_cache(maxsize=None)
14
+
15
+ GLOB_TO_REGEX = [
16
+ # I'm pretty sure that "**/*.py" should match a python file at the root, e.g. "setup.py"
17
+ # this first replacement ensures that this works in pre-commit
18
+ ("**/", ".*"),
19
+ ("**", ".*"),
20
+ ("*", "[^/]*"),
21
+ (".", "[.]"),
22
+ ("?", "."),
23
+ ("{", "("),
24
+ ("}", ")"),
25
+ (",", "|"),
26
+ ]
27
+ GLOB_TO_REGEX_MAP = dict(GLOB_TO_REGEX)
28
+ GLOB_TO_REGEX_REG = re.compile(
29
+ r"({})".format("|".join(re.escape(f) for f, r in GLOB_TO_REGEX))
30
+ )
31
+
32
+
33
+ def join(remote: str | None, branch: str) -> str:
34
+ """
35
+ Construct a full branch path with remote prefix if specified.
36
+
37
+ Args:
38
+ remote: The remote repository name
39
+ branch: The branch name.
40
+
41
+ Returns:
42
+ str: The full path to the branch, prefixed by the remote name if specified.
43
+ """
44
+ if remote:
45
+ return f"{remote}/{branch}"
46
+ else:
47
+ return branch
48
+
49
+
50
+ @overload
51
+ def git(*args: str, quiet: bool = False, capture: Literal[True]) -> str:
52
+ pass
53
+
54
+
55
+ @overload
56
+ def git(*args: str, quiet: bool = False) -> subprocess.CompletedProcess[str]:
57
+ pass
58
+
59
+
60
+ def git(
61
+ *args: str, quiet: bool = False, capture: bool = False
62
+ ) -> subprocess.CompletedProcess | str:
63
+ """
64
+ Execute a git command with the specified arguments.
65
+
66
+ Args:
67
+ *args: Command line arguments for git.
68
+
69
+ Returns:
70
+ A subprocess.CompletedProcess instance, if capture is False, else stdout of the process.
71
+
72
+ Raises:
73
+ subprocess.CalledProcessError: If the git command fails.
74
+ """
75
+ cmd = ["git"] + [str(arg).strip() for arg in args]
76
+ # if CONFIG.verbose:
77
+ # click.echo(cmd)
78
+ if capture:
79
+ output = subprocess.run(
80
+ cmd,
81
+ check=True,
82
+ text=True,
83
+ stdout=subprocess.PIPE,
84
+ stderr=subprocess.DEVNULL if quiet else None,
85
+ ).stdout.strip()
86
+ return output
87
+ else:
88
+ return subprocess.run(
89
+ cmd,
90
+ text=True,
91
+ check=True,
92
+ stdout=subprocess.DEVNULL if quiet else None,
93
+ stderr=subprocess.DEVNULL if quiet else None,
94
+ )
95
+
96
+
97
+ def get_tags(
98
+ pattern: str | None = None, start_rev: str = "HEAD", end_rev: str | None = None
99
+ ) -> list[str]:
100
+ """
101
+ Return all of the tags in the Git repository.
102
+
103
+ Args:
104
+ pattern: glob pattern for tag names
105
+ start_rev: list tags reachable from this commit
106
+ end_rev: list tags up until this commit
107
+ """
108
+ args = ["log", "--tags", "--format=%D", start_rev]
109
+ if end_rev:
110
+ args.extend(["--not", end_rev])
111
+
112
+ lines = git(*args, capture=True).splitlines()
113
+ tags = []
114
+ for line in lines:
115
+ parts = line.split(", ")
116
+ for part in parts:
117
+ if part.startswith("tag: "):
118
+ tag = part[5:]
119
+ if pattern is None or fnmatch.fnmatch(tag, pattern):
120
+ tags.append(tag)
121
+ return tags
122
+
123
+
124
+ def get_branches() -> list[str]:
125
+ """
126
+ Retrieve a list of all local branches in the git repository.
127
+
128
+ Returns:
129
+ A list of branch names.
130
+ """
131
+ output = git("branch", capture=True)
132
+ return [x.split()[-1] for x in output.splitlines()]
133
+
134
+
135
+ def checkout(remote: str | None, branch: str, create: bool = False) -> str:
136
+ """
137
+ Check out a specific branch, optionally creating it if it doesn't exist.
138
+
139
+ Args:
140
+ remote: The remote repository name
141
+ branch: The branch name to check out.
142
+ create: Whether to create the branch if it does not exist (default is False).
143
+ """
144
+ args = ["checkout"]
145
+
146
+ if remote:
147
+ if create and not branch_exists(branch):
148
+ args += ["--track", join(remote, branch)]
149
+ else:
150
+ args = ["branch", f"--set-upstream-to={join(remote, branch)}"]
151
+ else:
152
+ if create and not branch_exists(branch):
153
+ # if CONFIG.verbose:
154
+ # click.echo(f"Creating branch {branch}")
155
+ args += ["-b", branch]
156
+ else:
157
+ args += [branch]
158
+ git(*args)
159
+ return get_latest_commit(None, branch)
160
+
161
+
162
+ def current_rev() -> str:
163
+ """
164
+ Get the SHA for the current git revision.
165
+ """
166
+ return git("rev-parse", "HEAD", capture=True)
167
+
168
+
169
+ def branch_exists(branch: str) -> bool:
170
+ """
171
+ Return whether the given branch exists.
172
+
173
+ Args:
174
+ branch: branch name
175
+ """
176
+ try:
177
+ git("rev-parse", "--verify", branch, quiet=True)
178
+ except subprocess.CalledProcessError:
179
+ return False
180
+ return True
181
+
182
+
183
+ def get_latest_commit(remote: str | None, branch_name: str) -> str:
184
+ """
185
+ Fetch the latest commit hash from a specific branch.
186
+
187
+ Args:
188
+ remote: The remote repository name
189
+ branch_name: The name of the branch to fetch the latest commit from.
190
+
191
+ Returns:
192
+ str: The latest commit hash of the specified branch.
193
+
194
+ Raises:
195
+ subprocess.CalledProcessError: If the git command fails.
196
+ """
197
+ if remote:
198
+ git("fetch", remote, branch_name)
199
+ return git("rev-parse", join(remote, branch_name), capture=True)
200
+
201
+
202
+ def glob_to_regex(pattern: str) -> str:
203
+ """
204
+ Convert a glob to a regular expression.
205
+
206
+ Unlike fnmatch.translate, this handles ** syntax, which match across multiple
207
+ directories, and ensures that * globs only match within one level.
208
+
209
+ It currently does not work with paths that contain complex characters that
210
+ need to be escaped.
211
+ """
212
+
213
+ def replace(match: Match) -> str:
214
+ pat = match.groups()[0]
215
+ return GLOB_TO_REGEX_MAP[pat]
216
+
217
+ return GLOB_TO_REGEX_REG.sub(replace, pattern)
218
+
219
+
220
+ def regexes_to_regex(patterns: Iterable[str]) -> Pattern | None:
221
+ """
222
+ Convert a tuple of regex strings to a single regex Pattern
223
+ """
224
+ patterns = ["^" + p for p in patterns]
225
+ if len(patterns) > 1:
226
+ reg = "|\n".join(" " + p for p in patterns)
227
+ pattern = "(?x)(\n{}\n)$".format(reg)
228
+ elif len(patterns) == 1:
229
+ pattern = patterns[0] + "$"
230
+ else:
231
+ return None
232
+
233
+ try:
234
+ return re.compile(pattern)
235
+ except re.error:
236
+ print(pattern)
237
+ raise
238
+
239
+
240
+ @cache
241
+ def globs_to_regex(patterns: tuple[str, ...]) -> Pattern | None:
242
+ """
243
+ Convert a tuple of glob patterns to a single regex Pattern.
244
+ """
245
+ return regexes_to_regex(glob_to_regex(p) for p in patterns)
246
+
247
+
248
+ def filter_paths_regex(
249
+ paths: Iterable[str],
250
+ include: Pattern | None = None,
251
+ exclude: Pattern | None = None,
252
+ ) -> Iterator[str]:
253
+ """
254
+ Args:
255
+ paths: iterable of paths to filter
256
+ include: regex include pattern
257
+ exclude: regex exclude pattern
258
+ """
259
+ for path in paths:
260
+ if (not include or include.search(path)) and (
261
+ not exclude or not exclude.search(path)
262
+ ):
263
+ yield path
264
+
265
+
266
+ def filter_paths(
267
+ paths: Iterable[str],
268
+ include: tuple[str, ...] | None = None,
269
+ exclude: tuple[str, ...] | None = None,
270
+ ) -> list[str]:
271
+ """
272
+ Args:
273
+ paths: iterable of paths to filter
274
+ include: tuple of glob include patterns
275
+ exclude: tuple glob include patterns
276
+ """
277
+ return list(
278
+ filter_paths_regex(
279
+ paths,
280
+ globs_to_regex(include) if include else None,
281
+ globs_to_regex(exclude) if exclude else None,
282
+ )
283
+ )
284
+
285
+
286
+ class GitRepo:
287
+ """
288
+ Query and filter files from git and cache results
289
+ """
290
+
291
+ def __init__(self, root: str):
292
+ self.root: str = root
293
+
294
+ @cache
295
+ def files(self) -> list[str]:
296
+ """
297
+ Return all of the files in the git repo, at the current commit.
298
+ """
299
+ output = subprocess.check_output(["git", "ls-files"], cwd=self.root)
300
+ return [x for x in output.decode().split("\n") if x]
301
+
302
+ @cache
303
+ def file_matches(
304
+ self, include: tuple[str, ...] | None, exclude: tuple[str, ...] | None = None
305
+ ) -> list[str]:
306
+ """
307
+ Return file paths in the git repo at the current commit.
308
+
309
+ Args:
310
+ include: tuple of glob include patterns
311
+ exclude: tuple glob include patterns
312
+ """
313
+ return list(filter_paths(self.files(), include, exclude))
314
+
315
+ @cache
316
+ def folders(self) -> list[str]:
317
+ """
318
+ Return all of the folders in the git repo, at the current commit.
319
+ """
320
+ results = set()
321
+ for path in self.files():
322
+ while True:
323
+ path = os.path.dirname(path)
324
+ if not path:
325
+ break
326
+ results.add(path)
327
+ return sorted(results)
328
+
329
+ @cache
330
+ def folder_matches(
331
+ self, include: tuple[str, ...] | None, exclude: tuple[str, ...] | None = None
332
+ ) -> list[str]:
333
+ """
334
+ Return folder paths in the git repo at the current commit.
335
+
336
+ Args:
337
+ include: tuple of glob include patterns
338
+ exclude: tuple glob include patterns
339
+ """
340
+ return list(filter_paths(self.folders(), include, exclude))