cmem-plugin-git 1.0.0__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.
- cmem_plugin_git/__init__.py +1 -0
- cmem_plugin_git/autocompletion.py +86 -0
- cmem_plugin_git/download.py +272 -0
- cmem_plugin_git/git-download.svg +2 -0
- cmem_plugin_git/git-list.svg +2 -0
- cmem_plugin_git/git-upload.svg +2 -0
- cmem_plugin_git/list.py +161 -0
- cmem_plugin_git/parameters.py +180 -0
- cmem_plugin_git/remote.py +774 -0
- cmem_plugin_git/upload.py +261 -0
- cmem_plugin_git-1.0.0.dist-info/METADATA +82 -0
- cmem_plugin_git-1.0.0.dist-info/RECORD +14 -0
- cmem_plugin_git-1.0.0.dist-info/WHEEL +4 -0
- cmem_plugin_git-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""Object level access to a remote git repository
|
|
2
|
+
|
|
3
|
+
The tasks in this package never create a working tree. They fetch the commit and
|
|
4
|
+
the trees of a revision, walk them, and read only the blobs they actually need;
|
|
5
|
+
an upload builds a tree and a commit object on the fetched tip and sends that.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from collections.abc import Set as AbstractSet
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from fnmatch import fnmatch
|
|
16
|
+
from io import BytesIO
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from dulwich.client import GitClient, HTTPUnauthorized, SendPackResult, get_transport_and_path
|
|
20
|
+
from dulwich.errors import GitProtocolError, HangupException
|
|
21
|
+
from dulwich.ignore import IgnoreFilter, read_ignore_patterns
|
|
22
|
+
from dulwich.index import commit_tree
|
|
23
|
+
from dulwich.objects import Blob, Commit, ObjectID, ShaFile, Tree
|
|
24
|
+
from dulwich.pack import UnpackedObject, pack_objects_to_data
|
|
25
|
+
from dulwich.refs import Ref
|
|
26
|
+
from dulwich.repo import MemoryRepo
|
|
27
|
+
|
|
28
|
+
DEFAULT_USERNAME = "gitlab-ci-token"
|
|
29
|
+
"""Basic auth user name sent along with an access token.
|
|
30
|
+
|
|
31
|
+
GitHub ignores the user name when the password is a token, while GitLab checks
|
|
32
|
+
it and answers a mismatch with 401 or 500 rather than with an explanation. This
|
|
33
|
+
value is the one GitLab accepts for a repository token, so it is the default
|
|
34
|
+
that reaches both.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
DEFAULT_AUTHOR = "eccenca Corporate Memory <cmem@eccenca.com>"
|
|
38
|
+
"""Commit identity used when the executing user cannot be determined."""
|
|
39
|
+
|
|
40
|
+
DEFAULT_MESSAGE = "Update files from an eccenca Corporate Memory workflow"
|
|
41
|
+
"""Commit message an upload uses when it is not given one."""
|
|
42
|
+
|
|
43
|
+
LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
|
44
|
+
MAX_SYMLINK_HOPS = 10
|
|
45
|
+
MODE_TREE = 0o40000
|
|
46
|
+
MODE_SYMLINK = 0o120000
|
|
47
|
+
MODE_SUBMODULE = 0o160000
|
|
48
|
+
BRANCH_PREFIX = "refs/heads/"
|
|
49
|
+
TAG_PREFIX = "refs/tags/"
|
|
50
|
+
COMMIT_ID_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
51
|
+
WEB_URL_MARKERS = ("/-/", "/tree/", "/blob/", "/raw/")
|
|
52
|
+
MIN_REPOSITORY_DEPTH = 2
|
|
53
|
+
"""A repository path holds at least a namespace and a project name."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PushRejectedError(RuntimeError):
|
|
57
|
+
"""Raised when the remote branch moved between reading and writing it."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RemoteRef:
|
|
62
|
+
"""A branch or a tag, as advertised by the remote."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
kind: str
|
|
66
|
+
commit_id: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ResolvedRef:
|
|
71
|
+
"""The revision a task works on."""
|
|
72
|
+
|
|
73
|
+
commit_id: str
|
|
74
|
+
name: str
|
|
75
|
+
is_branch: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class RepositoryFile:
|
|
80
|
+
"""A file of the repository at one revision.
|
|
81
|
+
|
|
82
|
+
For a symbolic link, ``path`` and ``name`` are the link's own, while
|
|
83
|
+
``size``, ``mode`` and ``blob_id`` are those of the file it points at.
|
|
84
|
+
|
|
85
|
+
``size`` is None whenever the content has not been transferred. A git tree
|
|
86
|
+
records a name, a mode and an object id but not a length, so the size of a
|
|
87
|
+
file is only known once its content has been fetched - which is exactly what
|
|
88
|
+
the metadata fetch avoids.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
path: str
|
|
92
|
+
name: str
|
|
93
|
+
size: int | None
|
|
94
|
+
mode: str
|
|
95
|
+
blob_id: str
|
|
96
|
+
commit_id: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Selection:
|
|
101
|
+
"""The outcome of applying folder, expression and subfolder options."""
|
|
102
|
+
|
|
103
|
+
files: list[RepositoryFile] = field(default_factory=list)
|
|
104
|
+
warnings: list[str] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class UploadRequest:
|
|
109
|
+
"""Everything an upload needs to know, so that no function grows an argument list."""
|
|
110
|
+
|
|
111
|
+
branch: str
|
|
112
|
+
directory: str
|
|
113
|
+
contents: dict[str, bytes]
|
|
114
|
+
message: str
|
|
115
|
+
author: str
|
|
116
|
+
remove_obsolete: bool = False
|
|
117
|
+
honor_gitignore: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class UploadResult:
|
|
122
|
+
"""What an upload did."""
|
|
123
|
+
|
|
124
|
+
commit_id: str | None = None
|
|
125
|
+
branch: str = ""
|
|
126
|
+
branch_created: bool = False
|
|
127
|
+
written: list[str] = field(default_factory=list)
|
|
128
|
+
removed: list[str] = field(default_factory=list)
|
|
129
|
+
warnings: list[str] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def normalize_url(url: str) -> str:
|
|
133
|
+
"""Turn what a user pasted into a URL the git client can use
|
|
134
|
+
|
|
135
|
+
Accepts a clone URL as well as the address of the repository's web page,
|
|
136
|
+
with or without a trailing ``.git``.
|
|
137
|
+
"""
|
|
138
|
+
candidate = url.strip()
|
|
139
|
+
if not candidate:
|
|
140
|
+
raise ValueError("The repository URL is empty.")
|
|
141
|
+
parsed = urlparse(candidate)
|
|
142
|
+
if parsed.scheme not in {"http", "https"}:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"'{candidate}' is not an HTTP(S) repository URL. "
|
|
145
|
+
"These tasks talk to a repository over HTTP(S) only, so an SSH address such as "
|
|
146
|
+
"git@example.org:group/project.git cannot be used."
|
|
147
|
+
)
|
|
148
|
+
path = _without_web_suffix(parsed.path)
|
|
149
|
+
return f"{parsed.scheme}://{parsed.netloc}{path.rstrip('/')}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _without_web_suffix(path: str) -> str:
|
|
153
|
+
"""Cut what a forge appends to a repository path when it shows a page
|
|
154
|
+
|
|
155
|
+
A marker only counts once a namespace and a project name precede it. Without
|
|
156
|
+
that check a repository living in a group called 'raw' or 'tree' would be cut
|
|
157
|
+
down to the bare server address.
|
|
158
|
+
"""
|
|
159
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
160
|
+
for index in range(MIN_REPOSITORY_DEPTH, len(segments)):
|
|
161
|
+
if f"/{segments[index]}/" in WEB_URL_MARKERS:
|
|
162
|
+
return "/" + "/".join(segments[:index])
|
|
163
|
+
return path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _oid(value: str) -> ObjectID:
|
|
167
|
+
"""Turn a hexadecimal object id into what dulwich expects."""
|
|
168
|
+
return ObjectID(value.encode())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ref(name: str) -> Ref:
|
|
172
|
+
"""Turn a fully qualified reference name into what dulwich expects."""
|
|
173
|
+
return Ref(name.encode())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _wants(objects: list[ObjectID]) -> Callable[..., list[ObjectID]]:
|
|
177
|
+
"""Build a determine_wants callback asking for exactly these objects
|
|
178
|
+
|
|
179
|
+
The callback is invoked with the depth positionally by one transport and by
|
|
180
|
+
keyword by another, so it accepts whatever it is handed.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def determine(_refs: dict[Ref, ObjectID], *_args: object, **_kwargs: object) -> list[ObjectID]:
|
|
184
|
+
return objects
|
|
185
|
+
|
|
186
|
+
return determine
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _lfs_patterns(gitattributes: bytes) -> list[str]:
|
|
190
|
+
"""Read the path patterns that .gitattributes hands to the LFS filter."""
|
|
191
|
+
patterns = []
|
|
192
|
+
for raw_line in gitattributes.splitlines():
|
|
193
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
194
|
+
if not line or line.startswith("#"):
|
|
195
|
+
continue
|
|
196
|
+
fields = line.split()
|
|
197
|
+
if len(fields) > 1 and "filter=lfs" in fields[1:]:
|
|
198
|
+
patterns.append(fields[0])
|
|
199
|
+
return patterns
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _matches_pattern(path: str, pattern: str) -> bool:
|
|
203
|
+
"""Check a repository path against a .gitattributes pattern."""
|
|
204
|
+
if "/" in pattern.rstrip("/"):
|
|
205
|
+
return fnmatch(path, pattern.lstrip("/"))
|
|
206
|
+
return fnmatch(posixpath.basename(path), pattern)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _blob_of(store: MemoryRepo, sha: ObjectID) -> Blob:
|
|
210
|
+
"""Read one object as a blob."""
|
|
211
|
+
obj = store[sha]
|
|
212
|
+
if not isinstance(obj, Blob):
|
|
213
|
+
raise TypeError(f"Object {sha.decode()} is a {obj.type_name.decode()}, not a file.")
|
|
214
|
+
return obj
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tree_of(store: MemoryRepo, sha: ObjectID) -> Tree:
|
|
218
|
+
"""Read one object as a tree."""
|
|
219
|
+
obj = store[sha]
|
|
220
|
+
if not isinstance(obj, Tree):
|
|
221
|
+
raise TypeError(f"Object {sha.decode()} is a {obj.type_name.decode()}, not a folder.")
|
|
222
|
+
return obj
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _commit_of(store: MemoryRepo, sha: ObjectID) -> Commit:
|
|
226
|
+
"""Read one object as a commit."""
|
|
227
|
+
obj = store[sha]
|
|
228
|
+
if not isinstance(obj, Commit):
|
|
229
|
+
raise TypeError(f"Object {sha.decode()} is a {obj.type_name.decode()}, not a commit.")
|
|
230
|
+
return obj
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _holds(store: MemoryRepo, sha: ObjectID) -> bool:
|
|
234
|
+
"""Check whether an object has already been fetched."""
|
|
235
|
+
try:
|
|
236
|
+
store[sha]
|
|
237
|
+
except KeyError:
|
|
238
|
+
return False
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class GitRemote:
|
|
243
|
+
"""A remote git repository, reachable over HTTP(S)"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, url: str, token: str = "", username: str = DEFAULT_USERNAME) -> None:
|
|
246
|
+
self.url = normalize_url(url)
|
|
247
|
+
self.username = username.strip() or DEFAULT_USERNAME
|
|
248
|
+
self.token = token
|
|
249
|
+
if token:
|
|
250
|
+
client, path = get_transport_and_path(self.url, username=self.username, password=token)
|
|
251
|
+
else:
|
|
252
|
+
client, path = get_transport_and_path(self.url)
|
|
253
|
+
self.client: GitClient = client
|
|
254
|
+
self.path: bytes = path.encode() if isinstance(path, str) else path
|
|
255
|
+
self._advertised: dict[Ref, ObjectID] | None = None
|
|
256
|
+
self._symrefs: dict[Ref, Ref] = {}
|
|
257
|
+
|
|
258
|
+
@contextmanager
|
|
259
|
+
def _explaining(self, action: str) -> Iterator[None]:
|
|
260
|
+
"""Turn a transport failure into something a workflow designer can act on
|
|
261
|
+
|
|
262
|
+
A refused credential does not always arrive as 401. Some servers answer
|
|
263
|
+
the reference advertisement with a 500 when the user name does not suit
|
|
264
|
+
the token, and dulwich passes that on as the bare status code.
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
yield
|
|
268
|
+
except HTTPUnauthorized as error:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"{self.url} refused these credentials while {action}. Check the access token, "
|
|
271
|
+
"and that it carries the right to read - or, for an upload, to write - this "
|
|
272
|
+
"repository."
|
|
273
|
+
) from error
|
|
274
|
+
except GitProtocolError as error:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"{self.url} did not answer while {action}: {error}. When the status is 401 or "
|
|
277
|
+
"500, the user name is worth a look: GitLab checks it against the token and "
|
|
278
|
+
f"accepts '{DEFAULT_USERNAME}', while GitHub ignores it. The user name "
|
|
279
|
+
f"currently sent is '{self.username}'."
|
|
280
|
+
) from error
|
|
281
|
+
|
|
282
|
+
def _refs(self) -> dict[Ref, ObjectID]:
|
|
283
|
+
"""Ask the remote what it advertises, once per instance."""
|
|
284
|
+
if self._advertised is None:
|
|
285
|
+
with self._explaining("listing its branches and tags"):
|
|
286
|
+
result = self.client.get_refs(self.path)
|
|
287
|
+
self._advertised = {name: sha for name, sha in result.refs.items() if sha is not None}
|
|
288
|
+
self._symrefs = dict(result.symrefs)
|
|
289
|
+
return self._advertised
|
|
290
|
+
|
|
291
|
+
def list_refs(self) -> list[RemoteRef]:
|
|
292
|
+
"""List the branches and tags the remote advertises."""
|
|
293
|
+
refs = []
|
|
294
|
+
for name, commit_id in self._refs().items():
|
|
295
|
+
decoded = name.decode()
|
|
296
|
+
if decoded.endswith("^{}"):
|
|
297
|
+
continue
|
|
298
|
+
if decoded.startswith(BRANCH_PREFIX):
|
|
299
|
+
refs.append(RemoteRef(decoded[len(BRANCH_PREFIX) :], "branch", commit_id.decode()))
|
|
300
|
+
elif decoded.startswith(TAG_PREFIX):
|
|
301
|
+
refs.append(RemoteRef(decoded[len(TAG_PREFIX) :], "tag", commit_id.decode()))
|
|
302
|
+
return sorted(refs, key=lambda ref: (ref.kind, ref.name))
|
|
303
|
+
|
|
304
|
+
def default_branch(self) -> str:
|
|
305
|
+
"""Name the branch the remote's HEAD points at."""
|
|
306
|
+
self._refs()
|
|
307
|
+
head = self._symrefs.get(Ref(b"HEAD"), Ref(b"")).decode()
|
|
308
|
+
if head.startswith(BRANCH_PREFIX):
|
|
309
|
+
return head[len(BRANCH_PREFIX) :]
|
|
310
|
+
branches = [ref.name for ref in self.list_refs() if ref.kind == "branch"]
|
|
311
|
+
if not branches:
|
|
312
|
+
raise ValueError(f"The repository at {self.url} advertises no branch.")
|
|
313
|
+
if len(branches) > 1:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"The repository at {self.url} does not say which of its branches is the "
|
|
316
|
+
f"default one, so the revision has to be named. It offers: "
|
|
317
|
+
f"{', '.join(branches)}."
|
|
318
|
+
)
|
|
319
|
+
return branches[0]
|
|
320
|
+
|
|
321
|
+
def resolve(self, ref: str) -> ResolvedRef:
|
|
322
|
+
"""Turn a branch name, tag name or commit id into the revision to read."""
|
|
323
|
+
wanted = ref.strip()
|
|
324
|
+
if not wanted:
|
|
325
|
+
wanted = self.default_branch()
|
|
326
|
+
advertised = self._refs()
|
|
327
|
+
branch = advertised.get(_ref(f"{BRANCH_PREFIX}{wanted}"))
|
|
328
|
+
if branch is not None:
|
|
329
|
+
return ResolvedRef(branch.decode(), wanted, is_branch=True)
|
|
330
|
+
tag = advertised.get(_ref(f"{TAG_PREFIX}{wanted}"))
|
|
331
|
+
if tag is not None:
|
|
332
|
+
peeled = advertised.get(_ref(f"{TAG_PREFIX}{wanted}^{{}}"), tag)
|
|
333
|
+
return ResolvedRef(peeled.decode(), wanted, is_branch=False)
|
|
334
|
+
if COMMIT_ID_PATTERN.match(wanted):
|
|
335
|
+
return ResolvedRef(wanted, wanted, is_branch=False)
|
|
336
|
+
known = ", ".join(sorted(reference.name for reference in self.list_refs())) or "none"
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"'{wanted}' is neither a branch, a tag nor a commit id of {self.url}. "
|
|
339
|
+
f"The repository advertises: {known}."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def fetch_objects(
|
|
343
|
+
self,
|
|
344
|
+
target: MemoryRepo,
|
|
345
|
+
objects: list[ObjectID],
|
|
346
|
+
filter_spec: bytes | None = None,
|
|
347
|
+
depth: int | None = 1,
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Fetch objects into a store, retrying without the filter when it is refused."""
|
|
350
|
+
with self._explaining("fetching from it"):
|
|
351
|
+
try:
|
|
352
|
+
self.client.fetch(
|
|
353
|
+
self.path,
|
|
354
|
+
target,
|
|
355
|
+
determine_wants=_wants(objects),
|
|
356
|
+
depth=depth,
|
|
357
|
+
filter_spec=filter_spec,
|
|
358
|
+
protocol_version=2,
|
|
359
|
+
)
|
|
360
|
+
except (GitProtocolError, HangupException):
|
|
361
|
+
if filter_spec is None:
|
|
362
|
+
raise
|
|
363
|
+
self.client.fetch(self.path, target, determine_wants=_wants(objects), depth=depth)
|
|
364
|
+
|
|
365
|
+
def snapshot(self, ref: str) -> "Snapshot":
|
|
366
|
+
"""Fetch the commit and the trees of a revision, without any file content."""
|
|
367
|
+
resolved = self.resolve(ref)
|
|
368
|
+
store = MemoryRepo()
|
|
369
|
+
self.fetch_objects(store, [_oid(resolved.commit_id)], filter_spec=b"blob:none")
|
|
370
|
+
return Snapshot(remote=self, store=store, revision=resolved)
|
|
371
|
+
|
|
372
|
+
def fetch_blobs(self, blob_ids: list[str]) -> dict[str, bytes]:
|
|
373
|
+
"""Fetch the given blobs and nothing else
|
|
374
|
+
|
|
375
|
+
The objects go into an empty store on purpose: a store that already holds
|
|
376
|
+
the commit would make the client claim it has everything reachable from it,
|
|
377
|
+
and the remote would answer with an empty pack.
|
|
378
|
+
"""
|
|
379
|
+
if not blob_ids:
|
|
380
|
+
return {}
|
|
381
|
+
store = MemoryRepo()
|
|
382
|
+
with self._explaining("reading file content from it"):
|
|
383
|
+
self.client.fetch(
|
|
384
|
+
self.path,
|
|
385
|
+
store,
|
|
386
|
+
determine_wants=_wants([_oid(blob_id) for blob_id in blob_ids]),
|
|
387
|
+
protocol_version=2,
|
|
388
|
+
)
|
|
389
|
+
return {blob_id: _blob_of(store, _oid(blob_id)).data for blob_id in blob_ids}
|
|
390
|
+
|
|
391
|
+
def check_write_access(self) -> None:
|
|
392
|
+
"""Ask the remote for a write handshake and hang up again
|
|
393
|
+
|
|
394
|
+
The advertisement of git-receive-pack is only served to somebody who may
|
|
395
|
+
write, so reaching it is the check. Returning no reference update makes
|
|
396
|
+
the client stop before it sends anything.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def nothing(_refs: dict[Ref, ObjectID]) -> dict[Ref, ObjectID]:
|
|
400
|
+
return {}
|
|
401
|
+
|
|
402
|
+
def no_pack(
|
|
403
|
+
have: AbstractSet[ObjectID],
|
|
404
|
+
want: AbstractSet[ObjectID],
|
|
405
|
+
*,
|
|
406
|
+
ofs_delta: bool = True,
|
|
407
|
+
progress: Callable[[bytes], None] | None = None,
|
|
408
|
+
) -> tuple[int, Iterator[UnpackedObject]]:
|
|
409
|
+
_ = (have, want, ofs_delta, progress)
|
|
410
|
+
return 0, iter([])
|
|
411
|
+
|
|
412
|
+
with self._explaining("asking whether it may be written to"):
|
|
413
|
+
self.client.send_pack(self.path, nothing, no_pack)
|
|
414
|
+
|
|
415
|
+
def upload(self, request: UploadRequest) -> UploadResult:
|
|
416
|
+
"""Commit the given contents onto a branch and push it, once retrying a moved branch
|
|
417
|
+
|
|
418
|
+
Re-applying the same contents to the new tip is safe because an upload
|
|
419
|
+
sets paths to contents rather than replaying a difference.
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
return self._build_and_push(request)
|
|
423
|
+
except PushRejectedError:
|
|
424
|
+
return self._build_and_push(request)
|
|
425
|
+
|
|
426
|
+
def _base_of(self, branch: str) -> tuple[str | None, bool]:
|
|
427
|
+
"""Find the commit an upload builds on, and whether the branch is new."""
|
|
428
|
+
advertised = self._refs()
|
|
429
|
+
tip = advertised.get(_ref(f"{BRANCH_PREFIX}{branch}"))
|
|
430
|
+
if tip is not None:
|
|
431
|
+
return tip.decode(), False
|
|
432
|
+
if not advertised:
|
|
433
|
+
return None, True
|
|
434
|
+
return self.resolve(self.default_branch()).commit_id, True
|
|
435
|
+
|
|
436
|
+
def _build_and_push(self, request: UploadRequest) -> UploadResult:
|
|
437
|
+
"""Assemble the new tree, create the commit object and send the pack."""
|
|
438
|
+
self._advertised = None
|
|
439
|
+
base_commit_id, branch_created = self._base_of(request.branch)
|
|
440
|
+
store = MemoryRepo()
|
|
441
|
+
entries: dict[str, tuple[int, ObjectID]] = {}
|
|
442
|
+
if base_commit_id is not None:
|
|
443
|
+
self.fetch_objects(store, [_oid(base_commit_id)], filter_spec=b"blob:none")
|
|
444
|
+
entries = _flatten(store, _commit_of(store, _oid(base_commit_id)).tree)
|
|
445
|
+
result = UploadResult(branch=request.branch, branch_created=branch_created)
|
|
446
|
+
known = set(store.object_store)
|
|
447
|
+
new_entries = _apply(store, entries, request, result)
|
|
448
|
+
tree_id = commit_tree(
|
|
449
|
+
store.object_store,
|
|
450
|
+
[(path.encode(), sha, mode) for path, (mode, sha) in sorted(new_entries.items())],
|
|
451
|
+
)
|
|
452
|
+
base_tree = _commit_of(store, _oid(base_commit_id)).tree if base_commit_id else None
|
|
453
|
+
if tree_id == base_tree:
|
|
454
|
+
result.warnings.append(
|
|
455
|
+
"The repository already holds exactly these files, so nothing was committed."
|
|
456
|
+
)
|
|
457
|
+
return result
|
|
458
|
+
commit = _commit_object(tree_id, base_commit_id, request)
|
|
459
|
+
store.object_store.add_object(commit)
|
|
460
|
+
created = [store[ObjectID(sha)] for sha in set(store.object_store) - known]
|
|
461
|
+
self._send(
|
|
462
|
+
request.branch,
|
|
463
|
+
commit.id,
|
|
464
|
+
None if branch_created else base_commit_id,
|
|
465
|
+
created,
|
|
466
|
+
)
|
|
467
|
+
result.commit_id = commit.id.decode()
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
def _send(
|
|
471
|
+
self,
|
|
472
|
+
branch: str,
|
|
473
|
+
commit_id: ObjectID,
|
|
474
|
+
expected_tip: str | None,
|
|
475
|
+
objects: list[ShaFile],
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Push one branch, refusing to overwrite work that arrived meanwhile
|
|
478
|
+
|
|
479
|
+
The protocol itself would happily replace the branch, so the check that
|
|
480
|
+
the branch still points where it did when the commit was built happens
|
|
481
|
+
here. ``expected_tip`` is None for a branch that did not exist yet, which
|
|
482
|
+
also catches somebody else creating it in the meantime.
|
|
483
|
+
|
|
484
|
+
Only the objects this upload created are packed. Everything else the new
|
|
485
|
+
commit refers to is already on the remote, and asking the local store to
|
|
486
|
+
work that out would need the blobs the metadata fetch deliberately left
|
|
487
|
+
behind.
|
|
488
|
+
"""
|
|
489
|
+
ref = _ref(f"{BRANCH_PREFIX}{branch}")
|
|
490
|
+
expected = _oid(expected_tip) if expected_tip else None
|
|
491
|
+
|
|
492
|
+
def update_refs(refs: dict[Ref, ObjectID]) -> dict[Ref, ObjectID]:
|
|
493
|
+
if refs.get(ref) != expected:
|
|
494
|
+
raise PushRejectedError(
|
|
495
|
+
f"The branch '{branch}' moved while this task was working on it."
|
|
496
|
+
)
|
|
497
|
+
return {ref: commit_id}
|
|
498
|
+
|
|
499
|
+
def generate_pack_data(
|
|
500
|
+
have: AbstractSet[ObjectID],
|
|
501
|
+
want: AbstractSet[ObjectID],
|
|
502
|
+
*,
|
|
503
|
+
ofs_delta: bool = True,
|
|
504
|
+
progress: Callable[[bytes], None] | None = None,
|
|
505
|
+
) -> tuple[int, Iterator[UnpackedObject]]:
|
|
506
|
+
# The negotiation arguments are not consulted: what to send was
|
|
507
|
+
# decided while the commit was built. The names are dictated by the
|
|
508
|
+
# callback signature dulwich expects.
|
|
509
|
+
_ = (have, want, progress)
|
|
510
|
+
return pack_objects_to_data(objects, ofs_delta=ofs_delta)
|
|
511
|
+
|
|
512
|
+
with self._explaining("pushing to it"):
|
|
513
|
+
result = self.client.send_pack(self.path, update_refs, generate_pack_data)
|
|
514
|
+
_check_accepted(result, ref, branch)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _check_accepted(result: SendPackResult, ref: Ref, branch: str) -> None:
|
|
518
|
+
"""Fail unless the remote actually took the branch update
|
|
519
|
+
|
|
520
|
+
A remote that refuses one reference - a protected branch, a hook that
|
|
521
|
+
declines, a rule about who may push where - answers with a per reference
|
|
522
|
+
status rather than with an error, so a push that was thrown away looks like a
|
|
523
|
+
successful one until this is read.
|
|
524
|
+
"""
|
|
525
|
+
status = (result.ref_status or {}).get(ref)
|
|
526
|
+
if status is None:
|
|
527
|
+
return
|
|
528
|
+
if "fast forward" in status or "fast-forward" in status:
|
|
529
|
+
raise PushRejectedError(f"The remote refused to move '{branch}': {status}")
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"The remote refused to update the branch '{branch}': {status}. Nothing was committed. "
|
|
532
|
+
"A branch that is protected, or a hook that declines the push, reports itself this way."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _flatten(
|
|
537
|
+
store: MemoryRepo, tree_id: ObjectID, prefix: str = ""
|
|
538
|
+
) -> dict[str, tuple[int, ObjectID]]:
|
|
539
|
+
"""Turn a tree and its subtrees into a flat path to (mode, object id) mapping."""
|
|
540
|
+
entries: dict[str, tuple[int, ObjectID]] = {}
|
|
541
|
+
for entry in _tree_of(store, tree_id).items():
|
|
542
|
+
path = posixpath.join(prefix, entry.path.decode())
|
|
543
|
+
if entry.mode == MODE_TREE:
|
|
544
|
+
entries.update(_flatten(store, entry.sha, path))
|
|
545
|
+
else:
|
|
546
|
+
entries[path] = (entry.mode, entry.sha)
|
|
547
|
+
return entries
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _apply(
|
|
551
|
+
store: MemoryRepo,
|
|
552
|
+
entries: dict[str, tuple[int, ObjectID]],
|
|
553
|
+
request: UploadRequest,
|
|
554
|
+
result: UploadResult,
|
|
555
|
+
) -> dict[str, tuple[int, ObjectID]]:
|
|
556
|
+
"""Write the incoming files into the flattened tree, and remove what was asked for."""
|
|
557
|
+
updated: dict[str, tuple[int, ObjectID]] = dict(entries)
|
|
558
|
+
directory = request.directory.strip("/")
|
|
559
|
+
ignored = _ignore_filter(store, entries) if request.honor_gitignore else None
|
|
560
|
+
keep = set()
|
|
561
|
+
for name, content in sorted(request.contents.items()):
|
|
562
|
+
path = posixpath.join(directory, name) if directory else name
|
|
563
|
+
keep.add(path)
|
|
564
|
+
if ignored is not None and ignored.is_ignored(path):
|
|
565
|
+
result.warnings.append(f"'{path}' is excluded by .gitignore and was not committed.")
|
|
566
|
+
continue
|
|
567
|
+
blob = Blob.from_string(content)
|
|
568
|
+
store.object_store.add_object(blob)
|
|
569
|
+
updated[path] = (0o100644, blob.id)
|
|
570
|
+
result.written.append(path)
|
|
571
|
+
if request.remove_obsolete:
|
|
572
|
+
for path in sorted(updated):
|
|
573
|
+
if path not in keep and posixpath.dirname(path) == directory:
|
|
574
|
+
del updated[path]
|
|
575
|
+
result.removed.append(path)
|
|
576
|
+
return updated
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _ignore_filter(
|
|
580
|
+
store: MemoryRepo, entries: dict[str, tuple[int, ObjectID]]
|
|
581
|
+
) -> IgnoreFilter | None:
|
|
582
|
+
"""Read the repository's root .gitignore, when it has one."""
|
|
583
|
+
entry = entries.get(".gitignore")
|
|
584
|
+
if entry is None:
|
|
585
|
+
return None
|
|
586
|
+
return IgnoreFilter(read_ignore_patterns(BytesIO(_blob_of(store, entry[1]).data)))
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def clean_identity_part(value: str) -> str:
|
|
590
|
+
"""Strip what would break out of a commit header
|
|
591
|
+
|
|
592
|
+
A commit records its author on one line, framed by angle brackets, and
|
|
593
|
+
dulwich writes what it is given. A name carrying a newline would end that
|
|
594
|
+
line early and let the rest be read as further header fields.
|
|
595
|
+
"""
|
|
596
|
+
return "".join(character for character in value if character not in "\r\n<>").strip()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _commit_object(tree_id: ObjectID, parent: str | None, request: UploadRequest) -> Commit:
|
|
600
|
+
"""Create the commit object an upload pushes."""
|
|
601
|
+
commit = Commit()
|
|
602
|
+
commit.tree = tree_id
|
|
603
|
+
commit.parents = [_oid(parent)] if parent else []
|
|
604
|
+
identity = request.author.encode()
|
|
605
|
+
commit.author = identity
|
|
606
|
+
commit.committer = identity
|
|
607
|
+
commit.commit_time = commit.author_time = int(time.time())
|
|
608
|
+
commit.commit_timezone = commit.author_timezone = 0
|
|
609
|
+
commit.encoding = b"UTF-8"
|
|
610
|
+
message = request.message.strip() or DEFAULT_MESSAGE
|
|
611
|
+
commit.message = f"{message}\n".encode()
|
|
612
|
+
return commit
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class Snapshot:
|
|
616
|
+
"""One revision of a repository: its trees, and access to its blobs"""
|
|
617
|
+
|
|
618
|
+
def __init__(self, remote: GitRemote, store: MemoryRepo, revision: ResolvedRef) -> None:
|
|
619
|
+
self.remote = remote
|
|
620
|
+
self.store = store
|
|
621
|
+
self.revision = revision
|
|
622
|
+
self._entries: dict[str, tuple[int, ObjectID]] | None = None
|
|
623
|
+
|
|
624
|
+
def entries(self) -> dict[str, tuple[int, ObjectID]]:
|
|
625
|
+
"""Map every non directory path of the revision to its mode and object id."""
|
|
626
|
+
if self._entries is None:
|
|
627
|
+
commit = _commit_of(self.store, _oid(self.revision.commit_id))
|
|
628
|
+
self._entries = _flatten(self.store, commit.tree)
|
|
629
|
+
return self._entries
|
|
630
|
+
|
|
631
|
+
def folders(self) -> list[str]:
|
|
632
|
+
"""List the folders of the revision, for parameter autocompletion."""
|
|
633
|
+
folders = set()
|
|
634
|
+
for path in self.entries():
|
|
635
|
+
parts = path.split("/")[:-1]
|
|
636
|
+
for depth in range(1, len(parts) + 1):
|
|
637
|
+
folders.add("/".join(parts[:depth]))
|
|
638
|
+
return sorted(folders)
|
|
639
|
+
|
|
640
|
+
def read(self, blob_ids: list[str]) -> dict[str, bytes]:
|
|
641
|
+
"""Read blobs, fetching the ones the metadata fetch did not bring along."""
|
|
642
|
+
contents: dict[str, bytes] = {}
|
|
643
|
+
missing: list[str] = []
|
|
644
|
+
for blob_id in dict.fromkeys(blob_ids):
|
|
645
|
+
if _holds(self.store, _oid(blob_id)):
|
|
646
|
+
contents[blob_id] = _blob_of(self.store, _oid(blob_id)).data
|
|
647
|
+
else:
|
|
648
|
+
missing.append(blob_id)
|
|
649
|
+
if not missing:
|
|
650
|
+
return contents
|
|
651
|
+
try:
|
|
652
|
+
contents.update(self.remote.fetch_blobs(missing))
|
|
653
|
+
except (GitProtocolError, HangupException, KeyError):
|
|
654
|
+
self.remote.fetch_objects(self.store, [_oid(self.revision.commit_id)])
|
|
655
|
+
for blob_id in missing:
|
|
656
|
+
contents[blob_id] = _blob_of(self.store, _oid(blob_id)).data
|
|
657
|
+
return contents
|
|
658
|
+
|
|
659
|
+
def select(self, path: str, regex: str, no_subfolder: bool) -> Selection:
|
|
660
|
+
"""Apply the folder, expression and subfolder options to the revision."""
|
|
661
|
+
folder = path.strip("/")
|
|
662
|
+
expression = re.compile(regex) if regex else re.compile("^.*$")
|
|
663
|
+
selection = Selection()
|
|
664
|
+
candidates = [
|
|
665
|
+
(candidate, mode, sha)
|
|
666
|
+
for candidate, (mode, sha) in sorted(self.entries().items())
|
|
667
|
+
if _within(candidate, folder, no_subfolder)
|
|
668
|
+
and expression.search(posixpath.basename(candidate))
|
|
669
|
+
]
|
|
670
|
+
lfs = self._lfs_matcher()
|
|
671
|
+
targets = self._resolve_symlinks(candidates, selection)
|
|
672
|
+
for candidate, mode, sha in candidates:
|
|
673
|
+
resolved = targets.get(candidate) if mode == MODE_SYMLINK else (candidate, mode, sha)
|
|
674
|
+
if resolved is None:
|
|
675
|
+
continue
|
|
676
|
+
target_path, target_mode, target_sha = resolved
|
|
677
|
+
if target_mode == MODE_SUBMODULE:
|
|
678
|
+
selection.warnings.append(f"'{candidate}' is a submodule and was skipped.")
|
|
679
|
+
continue
|
|
680
|
+
if lfs(target_path):
|
|
681
|
+
selection.warnings.append(
|
|
682
|
+
f"'{candidate}' is stored with Git LFS, so only a pointer is in the "
|
|
683
|
+
"repository, and it was skipped."
|
|
684
|
+
)
|
|
685
|
+
continue
|
|
686
|
+
selection.files.append(self._file(candidate, target_mode, target_sha))
|
|
687
|
+
return selection
|
|
688
|
+
|
|
689
|
+
def _file(self, path: str, mode: int, sha: ObjectID) -> RepositoryFile:
|
|
690
|
+
"""Describe one selected file."""
|
|
691
|
+
return RepositoryFile(
|
|
692
|
+
path=path,
|
|
693
|
+
name=posixpath.basename(path),
|
|
694
|
+
size=self.store[sha].raw_length() if _holds(self.store, sha) else None,
|
|
695
|
+
mode=f"{mode:o}",
|
|
696
|
+
blob_id=sha.decode(),
|
|
697
|
+
commit_id=self.revision.commit_id,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _lfs_matcher(self) -> Callable[[str], bool]:
|
|
701
|
+
"""Build a test for whether a path is tracked by Git LFS."""
|
|
702
|
+
entry = self.entries().get(".gitattributes")
|
|
703
|
+
if entry is None:
|
|
704
|
+
return lambda _path: False
|
|
705
|
+
content = self.read([entry[1].decode()])[entry[1].decode()]
|
|
706
|
+
patterns = _lfs_patterns(content)
|
|
707
|
+
if not patterns:
|
|
708
|
+
return lambda _path: False
|
|
709
|
+
return lambda path: any(_matches_pattern(path, pattern) for pattern in patterns)
|
|
710
|
+
|
|
711
|
+
def _resolve_symlinks(
|
|
712
|
+
self, candidates: list[tuple[str, int, ObjectID]], selection: Selection
|
|
713
|
+
) -> dict[str, tuple[str, int, ObjectID]]:
|
|
714
|
+
"""Follow the symbolic links among the candidates to the file they point at."""
|
|
715
|
+
links = [candidate for candidate, mode, _ in candidates if mode == MODE_SYMLINK]
|
|
716
|
+
if not links:
|
|
717
|
+
return {}
|
|
718
|
+
entries = self.entries()
|
|
719
|
+
contents = self.read([entries[link][1].decode() for link in links])
|
|
720
|
+
resolved: dict[str, tuple[str, int, ObjectID]] = {}
|
|
721
|
+
for link in links:
|
|
722
|
+
target = self._follow(link, entries, contents, selection)
|
|
723
|
+
if target is not None:
|
|
724
|
+
resolved[link] = target
|
|
725
|
+
return resolved
|
|
726
|
+
|
|
727
|
+
def _follow(
|
|
728
|
+
self,
|
|
729
|
+
link: str,
|
|
730
|
+
entries: dict[str, tuple[int, ObjectID]],
|
|
731
|
+
contents: dict[str, bytes],
|
|
732
|
+
selection: Selection,
|
|
733
|
+
) -> tuple[str, int, ObjectID] | None:
|
|
734
|
+
"""Walk one chain of symbolic links, reporting where it does not end well."""
|
|
735
|
+
current = link
|
|
736
|
+
for _ in range(MAX_SYMLINK_HOPS):
|
|
737
|
+
blob_id = entries[current][1].decode()
|
|
738
|
+
raw = contents.get(blob_id) or self.read([blob_id])[blob_id]
|
|
739
|
+
contents[blob_id] = raw
|
|
740
|
+
target = posixpath.normpath(
|
|
741
|
+
posixpath.join(posixpath.dirname(current), raw.decode().strip())
|
|
742
|
+
)
|
|
743
|
+
if target.startswith("..") or posixpath.isabs(target):
|
|
744
|
+
selection.warnings.append(
|
|
745
|
+
f"'{link}' points outside the repository and was skipped."
|
|
746
|
+
)
|
|
747
|
+
return None
|
|
748
|
+
entry = entries.get(target)
|
|
749
|
+
if entry is None:
|
|
750
|
+
selection.warnings.append(
|
|
751
|
+
f"'{link}' points at '{target}', which the repository does not hold, "
|
|
752
|
+
"and was skipped."
|
|
753
|
+
)
|
|
754
|
+
return None
|
|
755
|
+
if entry[0] != MODE_SYMLINK:
|
|
756
|
+
return (target, entry[0], entry[1])
|
|
757
|
+
current = target
|
|
758
|
+
selection.warnings.append(f"'{link}' is a loop of symbolic links and was skipped.")
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _within(path: str, folder: str, no_subfolder: bool) -> bool:
|
|
763
|
+
"""Check whether a repository path sits in the selected folder."""
|
|
764
|
+
if folder and not path.startswith(f"{folder}/"):
|
|
765
|
+
return False
|
|
766
|
+
remainder = path[len(folder) + 1 :] if folder else path
|
|
767
|
+
return "/" not in remainder if no_subfolder else True
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def iter_warnings(warnings: list[str], limit: int = 10) -> Iterator[str]:
|
|
771
|
+
"""Yield at most a handful of warnings, and say how many were left out."""
|
|
772
|
+
yield from warnings[:limit]
|
|
773
|
+
if len(warnings) > limit:
|
|
774
|
+
yield f"{len(warnings) - limit} further messages of this kind were left out."
|