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.
@@ -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."