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.
- git_tide-0.1.0.dist-info/METADATA +22 -0
- git_tide-0.1.0.dist-info/RECORD +8 -0
- git_tide-0.1.0.dist-info/WHEEL +4 -0
- git_tide-0.1.0.dist-info/entry_points.txt +3 -0
- tide/__init__.py +1 -0
- tide/__main__.py +4 -0
- tide/core.py +1037 -0
- tide/gitutils.py +340 -0
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))
|