Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/repo.py: 37%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1217 statements  

1# repo.py -- For dealing with git repositories. 

2# Copyright (C) 2007 James Westby <jw+debian@jameswestby.net> 

3# Copyright (C) 2008-2013 Jelmer Vernooij <jelmer@jelmer.uk> 

4# 

5# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later 

6# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 

7# General Public License as published by the Free Software Foundation; version 2.0 

8# or (at your option) any later version. You can redistribute it and/or 

9# modify it under the terms of either of these two licenses. 

10# 

11# Unless required by applicable law or agreed to in writing, software 

12# distributed under the License is distributed on an "AS IS" BASIS, 

13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16# 

17# You should have received a copy of the licenses; if not, see 

18# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License 

19# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache 

20# License, Version 2.0. 

21# 

22 

23 

24"""Repository access. 

25 

26This module contains the base class for git repositories 

27(BaseRepo) and an implementation which uses a repository on 

28local disk (Repo). 

29 

30""" 

31 

32__all__ = [ 

33 "BASE_DIRECTORIES", 

34 "COMMONDIR", 

35 "CONTROLDIR", 

36 "DEFAULT_BRANCH", 

37 "DEFAULT_OFS_DELTA", 

38 "GITDIR", 

39 "INDEX_FILENAME", 

40 "OBJECTDIR", 

41 "REFSDIR", 

42 "REFSDIR_HEADS", 

43 "REFSDIR_TAGS", 

44 "WORKTREES", 

45 "BaseRepo", 

46 "DefaultIdentityNotFound", 

47 "InvalidUserIdentity", 

48 "InvalidWorktreeConfiguration", 

49 "MemoryRepo", 

50 "ParentsProvider", 

51 "Repo", 

52 "UnsupportedExtension", 

53 "UnsupportedVersion", 

54 "check_user_identity", 

55 "get_user_identity", 

56 "parse_graftpoints", 

57 "parse_shared_repository", 

58 "read_gitfile", 

59 "sanitize_user_identity", 

60 "serialize_graftpoints", 

61] 

62 

63import errno 

64import logging 

65import os 

66import stat 

67import sys 

68import time 

69import warnings 

70from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence 

71from io import BytesIO 

72from types import TracebackType 

73from typing import ( 

74 TYPE_CHECKING, 

75 Any, 

76 BinaryIO, 

77 TypeVar, 

78) 

79 

80if sys.version_info >= (3, 11): 

81 from typing import Self 

82else: 

83 from typing_extensions import Self 

84 

85if TYPE_CHECKING: 

86 # There are no circular imports here, but we try to defer imports as long 

87 # as possible to reduce start-up time for anything that doesn't need 

88 # these imports. 

89 from .attrs import GitAttributes 

90 from .config import ConditionMatcher, Config, ConfigFile, StackedConfig 

91 from .diff_tree import RenameDetector 

92 from .filters import FilterBlobNormalizer, FilterContext 

93 from .index import Index 

94 from .notes import Notes 

95 from .object_format import ObjectFormat 

96 from .object_store import BaseObjectStore, GraphWalker 

97 from .pack import UnpackedObject 

98 from .rebase import RebaseStateManager 

99 from .walk import Walker 

100 from .worktree import WorkTree 

101 

102from . import _deprecated_aliases, reflog 

103from .errors import ( 

104 NoIndexPresent, 

105 NotBlobError, 

106 NotCommitError, 

107 NotGitRepository, 

108 NotTagError, 

109 NotTreeError, 

110 RefFormatError, 

111) 

112from .file import ( 

113 PERM_EVERYBODY, 

114 PERM_GROUP, 

115 GitFile, 

116 SharedPerm, 

117 adjust_shared_perm, 

118 open_nofollow_read, 

119) 

120from .hooks import ( 

121 CommitMsgShellHook, 

122 Hook, 

123 PostCommitShellHook, 

124 PostReceiveShellHook, 

125 PreCommitShellHook, 

126 PreReceiveShellHook, 

127 UpdateShellHook, 

128) 

129from .object_store import ( 

130 DiskObjectStore, 

131 MemoryObjectStore, 

132 MissingObjectFinder, 

133 ObjectStoreGraphWalker, 

134 PackBasedObjectStore, 

135 PackCapableObjectStore, 

136 find_shallow, 

137) 

138from .objects import ( 

139 Blob, 

140 Commit, 

141 ObjectID, 

142 RawObjectID, 

143 ShaFile, 

144 Tag, 

145 Tree, 

146 check_hexsha, 

147 valid_hexsha, 

148) 

149from .pack import generate_unpacked_objects 

150from .refs import ( 

151 HEADREF, 

152 DictRefsContainer, 

153 DiskRefsContainer, 

154 Ref, 

155 RefsContainer, 

156 _set_branch_tracking, 

157 _set_default_branch, 

158 _set_head, 

159 _set_origin_head, 

160 extract_branch_name, 

161 is_per_worktree_ref, 

162 local_branch_name, 

163) 

164 

165logger = logging.getLogger(__name__) 

166 

167 

168__getattr__ = _deprecated_aliases( 

169 __name__, 

170 { 

171 "LOCAL_TAG_PREFIX": "dulwich.refs.LOCAL_TAG_PREFIX", 

172 "SYMREF": "dulwich.refs.SYMREF", 

173 "check_ref_format": "dulwich.refs.check_ref_format", 

174 "read_packed_refs": "dulwich.refs.read_packed_refs", 

175 "read_packed_refs_with_peeled": "dulwich.refs.read_packed_refs_with_peeled", 

176 "write_packed_refs": "dulwich.refs.write_packed_refs", 

177 }, 

178) 

179 

180 

181CONTROLDIR = ".git" 

182OBJECTDIR = "objects" 

183DEFAULT_OFS_DELTA = True 

184 

185T = TypeVar("T", bound="ShaFile") 

186REFSDIR = "refs" 

187REFSDIR_TAGS = "tags" 

188REFSDIR_HEADS = "heads" 

189INDEX_FILENAME = "index" 

190COMMONDIR = "commondir" 

191GITDIR = "gitdir" 

192WORKTREES = "worktrees" 

193 

194BASE_DIRECTORIES = [ 

195 ["branches"], 

196 [REFSDIR], 

197 [REFSDIR, REFSDIR_TAGS], 

198 [REFSDIR, REFSDIR_HEADS], 

199 ["hooks"], 

200 ["info"], 

201] 

202 

203DEFAULT_BRANCH = b"master" 

204 

205 

206class InvalidUserIdentity(Exception): 

207 """User identity is not of the format 'user <email>'.""" 

208 

209 def __init__(self, identity: str) -> None: 

210 """Initialize InvalidUserIdentity exception.""" 

211 self.identity = identity 

212 

213 

214class DefaultIdentityNotFound(Exception): 

215 """Default identity could not be determined.""" 

216 

217 

218# TODO(jelmer): Cache? 

219def _get_default_identity(env: Mapping[str, str] | None = None) -> tuple[str, str]: 

220 import socket 

221 

222 if env is None: 

223 env = os.environ 

224 

225 for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): 

226 username = env.get(name) 

227 if username: 

228 break 

229 else: 

230 username = None 

231 

232 try: 

233 import pwd 

234 except ImportError: 

235 fullname = None 

236 else: 

237 try: 

238 entry = pwd.getpwuid(os.getuid()) # type: ignore[attr-defined,unused-ignore] 

239 except KeyError: 

240 fullname = None 

241 else: 

242 if getattr(entry, "gecos", None): 

243 fullname = entry.pw_gecos.split(",")[0] 

244 else: 

245 fullname = None 

246 if username is None: 

247 username = entry.pw_name 

248 if not fullname: 

249 if username is None: 

250 raise DefaultIdentityNotFound("no username found") 

251 fullname = username 

252 email = env.get("EMAIL") 

253 if email is None: 

254 if username is None: 

255 raise DefaultIdentityNotFound("no username found") 

256 email = f"{username}@{socket.gethostname()}" 

257 return (fullname, email) 

258 

259 

260def get_user_identity(config: "Config", kind: str | None = None) -> bytes: 

261 """Determine the identity to use for new commits. 

262 

263 If kind is set, this first checks 

264 GIT_${KIND}_NAME and GIT_${KIND}_EMAIL. 

265 

266 If those variables are not set, then it will fall back 

267 to reading the user.name and user.email settings from 

268 the specified configuration. 

269 

270 If that also fails, then it will fall back to using 

271 the current users' identity as obtained from the host 

272 system (e.g. the gecos field, $EMAIL, $USER@$(hostname -f). 

273 

274 Args: 

275 config: Configuration stack to read from 

276 kind: Optional kind to return identity for, 

277 usually either "AUTHOR" or "COMMITTER". 

278 

279 Returns: 

280 A user identity 

281 """ 

282 user: bytes | None = None 

283 email: bytes | None = None 

284 if kind: 

285 user_uc = os.environ.get("GIT_" + kind + "_NAME") 

286 if user_uc is not None: 

287 user = user_uc.encode("utf-8") 

288 email_uc = os.environ.get("GIT_" + kind + "_EMAIL") 

289 if email_uc is not None: 

290 email = email_uc.encode("utf-8") 

291 if user is None: 

292 try: 

293 user = config.get(("user",), "name") 

294 except KeyError: 

295 user = None 

296 if email is None: 

297 try: 

298 email = config.get(("user",), "email") 

299 except KeyError: 

300 email = None 

301 default_user, default_email = _get_default_identity() 

302 if user is None: 

303 user = default_user.encode("utf-8") 

304 if email is None: 

305 email = default_email.encode("utf-8") 

306 if email.startswith(b"<") and email.endswith(b">"): 

307 email = email[1:-1] 

308 return user + b" <" + email + b">" 

309 

310 

311def check_user_identity(identity: bytes) -> None: 

312 """Verify that a user identity is formatted correctly. 

313 

314 Args: 

315 identity: User identity bytestring 

316 Raises: 

317 InvalidUserIdentity: Raised when identity is invalid 

318 """ 

319 try: 

320 _fst, snd = identity.split(b" <", 1) 

321 except ValueError as exc: 

322 raise InvalidUserIdentity(identity.decode("utf-8", "replace")) from exc 

323 if b">" not in snd: 

324 raise InvalidUserIdentity(identity.decode("utf-8", "replace")) 

325 if b"\0" in identity or b"\n" in identity: 

326 raise InvalidUserIdentity(identity.decode("utf-8", "replace")) 

327 

328 

329_IDENTITY_CRUD = bytes(range(33)) + b",:;<>\"\\'" 

330 

331 

332def _strip_identity_crud(value: bytes) -> bytes: 

333 r"""Strip characters using git's ``strbuf_addstr_without_crud`` rules. 

334 

335 Leading and trailing "crud" (bytes <= 32 as well as ``,:;<>"\'``) is 

336 stripped, and the delimiter characters ``\n``, ``<`` and ``>`` are 

337 dropped from the rest of the value. 

338 """ 

339 # Git does not handle embedded NUL bytes here, but check_user_identity 

340 # rejects them. 

341 return value.strip(_IDENTITY_CRUD).translate(None, b"\0\n<>") 

342 

343 

344def sanitize_user_identity(name: bytes, email: bytes) -> bytes: 

345 r"""Build a user identity with a sanitized name and email. 

346 

347 Matches git's ``fmt_ident`` behavior: the name and the email are each 

348 stripped of leading and trailing "crud" (bytes <= 32 as well as 

349 ``,:;<>"\'``), and the delimiter characters ``\n``, ``<`` and ``>`` 

350 are dropped from the middle. 

351 

352 Unlike ``check_user_identity``, this function never rejects its input. It 

353 is intended for identities outside the caller's control, such as those 

354 used by an importer to build commits. The result always passes 

355 ``check_user_identity``. 

356 

357 Args: 

358 name: User name bytestring 

359 email: Email bytestring 

360 

361 Returns: 

362 Identity bytestring of the format ``name <email>`` 

363 """ 

364 return _strip_identity_crud(name) + b" <" + _strip_identity_crud(email) + b">" 

365 

366 

367def parse_graftpoints( 

368 graftpoints: Iterable[bytes], 

369) -> dict[ObjectID, list[ObjectID]]: 

370 """Convert a list of graftpoints into a dict. 

371 

372 Args: 

373 graftpoints: Iterator of graftpoint lines 

374 

375 Each line is formatted as: 

376 <commit sha1> <parent sha1> [<parent sha1>]* 

377 

378 Resulting dictionary is: 

379 <commit sha1>: [<parent sha1>*] 

380 

381 https://git.wiki.kernel.org/index.php/GraftPoint 

382 """ 

383 grafts: dict[ObjectID, list[ObjectID]] = {} 

384 for line in graftpoints: 

385 raw_graft = line.split(None, 1) 

386 

387 commit = ObjectID(raw_graft[0]) 

388 if len(raw_graft) == 2: 

389 parents = [ObjectID(p) for p in raw_graft[1].split()] 

390 else: 

391 parents = [] 

392 

393 for sha in [commit, *parents]: 

394 check_hexsha(sha, "Invalid graftpoint") 

395 

396 grafts[commit] = parents 

397 return grafts 

398 

399 

400def serialize_graftpoints(graftpoints: Mapping[ObjectID, Sequence[ObjectID]]) -> bytes: 

401 """Convert a dictionary of grafts into string. 

402 

403 The graft dictionary is: 

404 <commit sha1>: [<parent sha1>*] 

405 

406 Each line is formatted as: 

407 <commit sha1> <parent sha1> [<parent sha1>]* 

408 

409 https://git.wiki.kernel.org/index.php/GraftPoint 

410 

411 """ 

412 graft_lines = [] 

413 for commit, parents in graftpoints.items(): 

414 if parents: 

415 graft_lines.append(commit + b" " + b" ".join(parents)) 

416 else: 

417 graft_lines.append(commit) 

418 return b"\n".join(graft_lines) 

419 

420 

421def _set_filesystem_hidden(path: str) -> None: 

422 """Mark path as to be hidden if supported by platform and filesystem. 

423 

424 On win32 uses SetFileAttributesW api: 

425 <https://docs.microsoft.com/windows/desktop/api/fileapi/nf-fileapi-setfileattributesw> 

426 """ 

427 if sys.platform == "win32": 

428 import ctypes 

429 from ctypes.wintypes import BOOL, DWORD, LPCWSTR 

430 

431 FILE_ATTRIBUTE_HIDDEN = 2 

432 SetFileAttributesW = ctypes.WINFUNCTYPE(BOOL, LPCWSTR, DWORD)( 

433 ("SetFileAttributesW", ctypes.windll.kernel32) 

434 ) 

435 

436 if isinstance(path, bytes): 

437 path = os.fsdecode(path) 

438 if not SetFileAttributesW(path, FILE_ATTRIBUTE_HIDDEN): 

439 pass # Could raise or log `ctypes.WinError()` here 

440 

441 # Could implement other platform specific filesystem hiding here 

442 

443 

444def parse_shared_repository(value: str | bytes | bool) -> "SharedPerm | None": 

445 """Parse core.sharedRepository configuration value. 

446 

447 Args: 

448 value: Configuration value (string, bytes, or boolean) 

449 

450 Returns: 

451 SharedPerm to apply, or None to leave permissions to the umask 

452 """ 

453 if isinstance(value, bytes): 

454 value = value.decode("utf-8", errors="replace") 

455 

456 # Handle boolean values 

457 if isinstance(value, bool): 

458 # true = group (same as "group"), false = umask 

459 return PERM_GROUP if value else None 

460 

461 # Handle string values 

462 value_lower = value.lower() 

463 

464 if value_lower in ("false", "0", "", "umask"): 

465 # Use umask (no adjustment) 

466 return None 

467 

468 if value_lower in ("true", "1", "group"): 

469 return PERM_GROUP 

470 

471 if value_lower in ("all", "world", "everybody", "2"): 

472 # Others gain read, and execute on directories, but never write. 

473 return PERM_EVERYBODY 

474 

475 # Try to parse as octal. Unlike the named settings, this states the mode 

476 # outright rather than loosening what the umask produced. 

477 if value.startswith("0"): 

478 try: 

479 mode = int(value, 8) 

480 except ValueError: 

481 pass 

482 else: 

483 return SharedPerm(tweak=mode, replace=True) 

484 

485 # Default to umask for unrecognized values 

486 return None 

487 

488 

489def _enable_relative_worktrees_extension(repo: "Repo") -> None: 

490 """Enable the relativeworktrees extension in repository config. 

491 

492 This sets core.repositoryformatversion to 1 (if not already) and 

493 enables the extensions.relativeworktrees extension. 

494 

495 Args: 

496 repo: The repository to configure 

497 """ 

498 config = repo.get_config() 

499 

500 # Ensure repository format version is at least 1 

501 try: 

502 version = int(config.get(("core",), "repositoryformatversion")) 

503 except KeyError: 

504 version = 0 

505 

506 if version < 1: 

507 config.set(("core",), "repositoryformatversion", "1") 

508 

509 # Enable the relativeworktrees extension 

510 config.set(("extensions",), "relativeworktrees", True) 

511 config.write_to_path() 

512 

513 

514class ParentsProvider: 

515 """Provider for commit parent information.""" 

516 

517 def __init__( 

518 self, 

519 store: "BaseObjectStore", 

520 grafts: dict[ObjectID, list[ObjectID]] = {}, 

521 shallows: Iterable[ObjectID] = [], 

522 ) -> None: 

523 """Initialize ParentsProvider. 

524 

525 Args: 

526 store: Object store to use 

527 grafts: Graft information 

528 shallows: Shallow commit SHAs 

529 """ 

530 self.store = store 

531 self.grafts = grafts 

532 self.shallows = set(shallows) 

533 

534 # Get commit graph once at initialization for performance 

535 self.commit_graph = store.get_commit_graph() 

536 

537 def get_parents( 

538 self, commit_id: ObjectID, commit: Commit | None = None 

539 ) -> list[ObjectID]: 

540 """Get parents for a commit using the parents provider.""" 

541 try: 

542 return self.grafts[commit_id] 

543 except KeyError: 

544 pass 

545 if commit_id in self.shallows: 

546 return [] 

547 

548 # Try to use commit graph for faster parent lookup 

549 if self.commit_graph: 

550 parents = self.commit_graph.get_parents(commit_id) 

551 if parents is not None: 

552 return parents 

553 

554 # Fallback to reading the commit object 

555 if commit is None: 

556 obj = self.store[commit_id] 

557 if not isinstance(obj, Commit): 

558 raise ValueError( 

559 f"Expected Commit object for commit_id {commit_id.decode()}, " 

560 f"got {type(obj).__name__}. This usually means a reference " 

561 f"points to a {type(obj).__name__} object instead of a Commit." 

562 ) 

563 commit = obj 

564 result: list[ObjectID] = commit.parents 

565 return result 

566 

567 

568class BaseRepo: 

569 """Base class for a git repository. 

570 

571 This base class is meant to be used for Repository implementations that e.g. 

572 work on top of a different transport than a standard filesystem path. 

573 

574 Attributes: 

575 object_store: Dictionary-like object for accessing 

576 the objects 

577 refs: Dictionary-like object with the refs in this 

578 repository 

579 """ 

580 

581 def __init__( 

582 self, 

583 object_store: "PackCapableObjectStore", 

584 refs: RefsContainer, 

585 object_format: "ObjectFormat | None" = None, 

586 ) -> None: 

587 """Open a repository. 

588 

589 This shouldn't be called directly, but rather through one of the 

590 base classes, such as MemoryRepo or Repo. 

591 

592 Args: 

593 object_store: Object store to use 

594 refs: Refs container to use 

595 object_format: Hash algorithm to use (if None, will use object_store's format) 

596 """ 

597 self.object_store = object_store 

598 self.refs = refs 

599 

600 self._graftpoints: dict[ObjectID, list[ObjectID]] = {} 

601 self.hooks: dict[str, Hook] = {} 

602 if object_format is None: 

603 self.object_format: ObjectFormat = object_store.object_format 

604 else: 

605 self.object_format = object_format 

606 

607 def _determine_file_mode(self) -> bool: 

608 """Probe the file-system to determine whether permissions can be trusted. 

609 

610 Returns: True if permissions can be trusted, False otherwise. 

611 """ 

612 raise NotImplementedError(self._determine_file_mode) 

613 

614 def _determine_symlinks(self) -> bool: 

615 """Probe the filesystem to determine whether symlinks can be created. 

616 

617 Returns: True if symlinks can be created, False otherwise. 

618 """ 

619 # For now, just mimic the old behaviour 

620 return sys.platform != "win32" 

621 

622 def _init_files( 

623 self, 

624 bare: bool, 

625 symlinks: bool | None = None, 

626 format: int | None = None, 

627 shared_repository: str | bool | None = None, 

628 object_format: str | None = None, 

629 ) -> None: 

630 """Initialize a default set of named files.""" 

631 from .config import ConfigFile 

632 

633 self._put_named_file("description", b"Unnamed repository") 

634 f = BytesIO() 

635 cf = ConfigFile() 

636 

637 # Determine the appropriate format version 

638 if object_format == "sha256": 

639 # SHA256 requires format version 1 

640 if format is None: 

641 format = 1 

642 elif format != 1: 

643 raise ValueError( 

644 "SHA256 object format requires repository format version 1" 

645 ) 

646 else: 

647 # SHA1 (default) can use format 0 or 1 

648 if format is None: 

649 format = 0 

650 

651 if format not in (0, 1): 

652 raise ValueError(f"Unsupported repository format version: {format}") 

653 

654 cf.set("core", "repositoryformatversion", str(format)) 

655 

656 # Set object format extension if using SHA256 

657 if object_format == "sha256": 

658 cf.set("extensions", "objectformat", "sha256") 

659 

660 # Set hash algorithm based on object format 

661 from .object_format import get_object_format 

662 

663 self.object_format = get_object_format(object_format) 

664 

665 if self._determine_file_mode(): 

666 cf.set("core", "filemode", True) 

667 else: 

668 cf.set("core", "filemode", False) 

669 

670 if symlinks is None and not bare: 

671 symlinks = self._determine_symlinks() 

672 

673 if symlinks is False: 

674 cf.set("core", "symlinks", symlinks) 

675 

676 # On macOS, set precomposeunicode to true since HFS+/APFS 

677 # returns filenames in NFD (decomposed) Unicode form 

678 if sys.platform == "darwin": 

679 cf.set("core", "precomposeunicode", True) 

680 

681 cf.set("core", "bare", bare) 

682 cf.set("core", "logallrefupdates", True) 

683 

684 # Set shared repository if specified 

685 if shared_repository is not None: 

686 if isinstance(shared_repository, bool): 

687 cf.set("core", "sharedRepository", shared_repository) 

688 else: 

689 cf.set("core", "sharedRepository", shared_repository) 

690 

691 cf.write_to_file(f) 

692 self._put_named_file("config", f.getvalue()) 

693 self._put_named_file(os.path.join("info", "exclude"), b"") 

694 

695 # Allow subclasses to handle config initialization 

696 self._init_config(cf) 

697 

698 def _init_config(self, config: "ConfigFile") -> None: 

699 """Initialize repository configuration. 

700 

701 This method can be overridden by subclasses to handle config initialization. 

702 

703 Args: 

704 config: The ConfigFile object that was just created 

705 """ 

706 # Default implementation does nothing 

707 

708 def get_named_file(self, path: str) -> BinaryIO | None: 

709 """Get a file from the control dir with a specific name. 

710 

711 Although the filename should be interpreted as a filename relative to 

712 the control dir in a disk-based Repo, the object returned need not be 

713 pointing to a file in that location. 

714 

715 Args: 

716 path: The path to the file, relative to the control dir. 

717 Returns: An open file object, or None if the file does not exist. 

718 """ 

719 raise NotImplementedError(self.get_named_file) 

720 

721 def _put_named_file(self, path: str, contents: bytes) -> None: 

722 """Write a file to the control dir with the given name and contents. 

723 

724 Args: 

725 path: The path to the file, relative to the control dir. 

726 contents: A string to write to the file. 

727 """ 

728 raise NotImplementedError(self._put_named_file) 

729 

730 def _del_named_file(self, path: str) -> None: 

731 """Delete a file in the control directory with the given name.""" 

732 raise NotImplementedError(self._del_named_file) 

733 

734 def open_index(self, config: "Config | None" = None) -> "Index": 

735 """Open the index for this repository. 

736 

737 Args: 

738 config: Configuration to consult for index settings. If None, 

739 implementations may fall back to ``self.get_config_stack()``. 

740 

741 Raises: 

742 NoIndexPresent: If no index is present 

743 Returns: The matching `Index` 

744 """ 

745 raise NotImplementedError(self.open_index) 

746 

747 def _change_object_format(self, object_format_name: str) -> None: 

748 """Change the object format of this repository. 

749 

750 This can only be done if the object store is empty (no objects written yet). 

751 

752 Args: 

753 object_format_name: Name of the new object format (e.g., "sha1", "sha256") 

754 

755 Raises: 

756 AssertionError: If the object store is not empty 

757 """ 

758 # Check if object store has any objects 

759 for _ in self.object_store: 

760 raise AssertionError( 

761 "Cannot change object format: repository already contains objects" 

762 ) 

763 

764 # Update the object format 

765 from .object_format import get_object_format 

766 

767 new_format = get_object_format(object_format_name) 

768 self.object_format = new_format 

769 self.object_store.object_format = new_format 

770 

771 # Update config file 

772 config = self.get_config() 

773 

774 if object_format_name == "sha1": 

775 # For SHA-1, explicitly remove objectformat extension if present 

776 try: 

777 config.remove("extensions", "objectformat") 

778 except KeyError: 

779 pass 

780 else: 

781 # For non-SHA-1 formats, set repositoryformatversion to 1 and objectformat extension 

782 config.set("core", "repositoryformatversion", "1") 

783 config.set("extensions", "objectformat", object_format_name) 

784 

785 config.write_to_path() 

786 

787 def fetch( 

788 self, 

789 target: "BaseRepo", 

790 determine_wants: Callable[[Mapping[Ref, ObjectID], int | None], list[ObjectID]] 

791 | None = None, 

792 progress: Callable[..., None] | None = None, 

793 depth: int | None = None, 

794 ) -> dict[Ref, ObjectID]: 

795 """Fetch objects into another repository. 

796 

797 Args: 

798 target: The target repository 

799 determine_wants: Optional function to determine what refs to 

800 fetch. 

801 progress: Optional progress function 

802 depth: Optional shallow fetch depth 

803 Returns: The local refs 

804 """ 

805 # Fix object format if needed 

806 if self.object_format != target.object_format: 

807 # Change the target repo's format if it's empty 

808 target._change_object_format(self.object_format.name) 

809 

810 if determine_wants is None: 

811 determine_wants = target.object_store.determine_wants_all 

812 count, pack_data = self.fetch_pack_data( 

813 determine_wants, 

814 target.get_graph_walker(), 

815 progress=progress, 

816 depth=depth, 

817 ) 

818 target.object_store.add_pack_data(count, pack_data, progress) 

819 return self.get_refs() 

820 

821 def fetch_pack_data( 

822 self, 

823 determine_wants: Callable[[Mapping[Ref, ObjectID], int | None], list[ObjectID]], 

824 graph_walker: "GraphWalker", 

825 progress: Callable[[bytes], None] | None, 

826 *, 

827 get_tagged: Callable[[], dict[ObjectID, ObjectID]] | None = None, 

828 depth: int | None = None, 

829 ) -> tuple[int, Iterator["UnpackedObject"]]: 

830 """Fetch the pack data required for a set of revisions. 

831 

832 Args: 

833 determine_wants: Function that takes a dictionary with heads 

834 and returns the list of heads to fetch. 

835 graph_walker: Object that can iterate over the list of revisions 

836 to fetch and has an "ack" method that will be called to acknowledge 

837 that a revision is present. 

838 progress: Simple progress function that will be called with 

839 updated progress strings. 

840 get_tagged: Function that returns a dict of pointed-to sha -> 

841 tag sha for including tags. 

842 depth: Shallow fetch depth 

843 Returns: count and iterator over pack data 

844 """ 

845 missing_objects = self.find_missing_objects( 

846 determine_wants, graph_walker, progress, get_tagged=get_tagged, depth=depth 

847 ) 

848 if missing_objects is None: 

849 return 0, iter([]) 

850 remote_has = missing_objects.get_remote_has() 

851 object_ids = list(missing_objects) 

852 return len(object_ids), generate_unpacked_objects( 

853 self.object_store, object_ids, progress=progress, other_haves=remote_has 

854 ) 

855 

856 def find_missing_objects( 

857 self, 

858 determine_wants: Callable[[Mapping[Ref, ObjectID], int | None], list[ObjectID]], 

859 graph_walker: "GraphWalker", 

860 progress: Callable[[bytes], None] | None, 

861 *, 

862 get_tagged: Callable[[], dict[ObjectID, ObjectID]] | None = None, 

863 depth: int | None = None, 

864 ) -> MissingObjectFinder | None: 

865 """Fetch the missing objects required for a set of revisions. 

866 

867 Args: 

868 determine_wants: Function that takes a dictionary with heads 

869 and returns the list of heads to fetch. 

870 graph_walker: Object that can iterate over the list of revisions 

871 to fetch and has an "ack" method that will be called to acknowledge 

872 that a revision is present. 

873 progress: Simple progress function that will be called with 

874 updated progress strings. 

875 get_tagged: Function that returns a dict of pointed-to sha -> 

876 tag sha for including tags. 

877 depth: Shallow fetch depth 

878 Returns: iterator over objects, with __len__ implemented 

879 """ 

880 # Filter out refs pointing to missing objects to avoid errors downstream. 

881 # This makes Dulwich more robust when dealing with broken refs on disk. 

882 # Previously serialize_refs() did this filtering as a side-effect. 

883 all_refs = self.get_refs() 

884 refs: dict[Ref, ObjectID] = {} 

885 for ref, sha in all_refs.items(): 

886 if sha in self.object_store: 

887 refs[ref] = sha 

888 else: 

889 logger.warning( 

890 "ref %s points at non-present sha %s", 

891 ref.decode("utf-8", "replace"), 

892 sha.decode("ascii"), 

893 ) 

894 

895 wants = determine_wants(refs, depth) 

896 if not isinstance(wants, list): 

897 raise TypeError("determine_wants() did not return a list") 

898 

899 current_shallow = set(getattr(graph_walker, "shallow", set())) 

900 

901 unshallow: set[ObjectID] = set() 

902 if depth not in (None, 0): 

903 assert depth is not None 

904 shallow, not_shallow = find_shallow(self.object_store, wants, depth) 

905 # Only update if graph_walker has shallow attribute 

906 walker_shallow: set[ObjectID] | None = getattr( 

907 graph_walker, "shallow", None 

908 ) 

909 if walker_shallow is not None: 

910 unshallow = not_shallow & current_shallow 

911 # Commits that were a shallow boundary but are now being 

912 # deepened past must drop out of the boundary, otherwise their 

913 # parents stay unreachable and never get transferred. 

914 walker_shallow.update(shallow - not_shallow) 

915 walker_shallow.difference_update(unshallow) 

916 new_shallow = walker_shallow - current_shallow 

917 setattr(graph_walker, "unshallow", unshallow) 

918 update_shallow = getattr(graph_walker, "update_shallow", None) 

919 if update_shallow is not None: 

920 update_shallow(new_shallow, unshallow) 

921 else: 

922 unshallow = getattr(graph_walker, "unshallow", set()) 

923 

924 if wants == []: 

925 # TODO(dborowitz): find a way to short-circuit that doesn't change 

926 # this interface. 

927 

928 if getattr(graph_walker, "shallow", set()) or unshallow: 

929 # Do not send a pack in shallow short-circuit path 

930 return None 

931 

932 # Return an actual MissingObjectFinder with empty wants 

933 return MissingObjectFinder( 

934 self.object_store, 

935 haves=[], 

936 wants=[], 

937 ) 

938 

939 # If the graph walker is set up with an implementation that can 

940 # ACK/NAK to the wire, it will write data to the client through 

941 # this call as a side-effect. 

942 haves = self.object_store.find_common_revisions(graph_walker) 

943 

944 # Deal with shallow requests separately because the haves do 

945 # not reflect what objects are missing 

946 if getattr(graph_walker, "shallow", set()) or unshallow: 

947 # TODO: filter the haves commits from iter_shas. the specific 

948 # commits aren't missing. 

949 haves = [] 

950 

951 parents_provider = ParentsProvider( 

952 self.object_store, 

953 shallows=getattr(graph_walker, "shallow", current_shallow), 

954 ) 

955 

956 def get_parents(commit: Commit) -> list[ObjectID]: 

957 """Get parents for a commit using the parents provider. 

958 

959 Args: 

960 commit: Commit object 

961 

962 Returns: 

963 List of parent commit SHAs 

964 """ 

965 return parents_provider.get_parents(commit.id, commit) 

966 

967 return MissingObjectFinder( 

968 self.object_store, 

969 haves=haves, 

970 wants=wants, 

971 shallow=getattr(graph_walker, "shallow", set()), 

972 progress=progress, 

973 get_tagged=get_tagged, 

974 get_parents=get_parents, 

975 ) 

976 

977 def generate_pack_data( 

978 self, 

979 have: set[ObjectID], 

980 want: set[ObjectID], 

981 *, 

982 shallow: set[ObjectID] | None = None, 

983 progress: Callable[[str], None] | None = None, 

984 ofs_delta: bool | None = None, 

985 ) -> tuple[int, Iterator["UnpackedObject"]]: 

986 """Generate pack data objects for a set of wants/haves. 

987 

988 Args: 

989 have: List of SHA1s of objects that should not be sent 

990 want: List of SHA1s of objects that should be sent 

991 shallow: Set of shallow commit SHA1s to skip (defaults to repo's shallow commits) 

992 ofs_delta: Whether OFS deltas can be included 

993 progress: Optional progress reporting method 

994 """ 

995 if shallow is None: 

996 shallow = self.get_shallow() 

997 return self.object_store.generate_pack_data( 

998 have, 

999 want, 

1000 shallow=shallow, 

1001 progress=progress, 

1002 ofs_delta=ofs_delta if ofs_delta is not None else DEFAULT_OFS_DELTA, 

1003 ) 

1004 

1005 def get_graph_walker( 

1006 self, heads: list[ObjectID] | None = None 

1007 ) -> ObjectStoreGraphWalker: 

1008 """Retrieve a graph walker. 

1009 

1010 A graph walker is used by a remote repository (or proxy) 

1011 to find out which objects are present in this repository. 

1012 

1013 Args: 

1014 heads: Repository heads to use (optional) 

1015 Returns: A graph walker object 

1016 """ 

1017 if heads is None: 

1018 heads = [ 

1019 sha 

1020 for sha in self.refs.as_dict(Ref(b"refs/heads")).values() 

1021 if sha in self.object_store 

1022 ] 

1023 parents_provider = ParentsProvider(self.object_store) 

1024 return ObjectStoreGraphWalker( 

1025 heads, 

1026 parents_provider.get_parents, 

1027 shallow=self.get_shallow(), 

1028 update_shallow=self.update_shallow, 

1029 ) 

1030 

1031 def get_refs(self) -> dict[Ref, ObjectID]: 

1032 """Get dictionary with all refs. 

1033 

1034 Returns: A ``dict`` mapping ref names to SHA1s 

1035 """ 

1036 return self.refs.as_dict() 

1037 

1038 def head(self) -> ObjectID: 

1039 """Return the SHA1 pointed at by HEAD.""" 

1040 # TODO: move this method to WorkTree 

1041 return self.refs[HEADREF] 

1042 

1043 def _get_object(self, sha: ObjectID | RawObjectID, cls: type[T]) -> T: 

1044 assert len(sha) in ( 

1045 self.object_format.oid_length, 

1046 self.object_format.hex_length, 

1047 ) 

1048 ret = self.get_object(sha) 

1049 if not isinstance(ret, cls): 

1050 if cls is Commit: 

1051 raise NotCommitError(ret.id) 

1052 elif cls is Blob: 

1053 raise NotBlobError(ret.id) 

1054 elif cls is Tree: 

1055 raise NotTreeError(ret.id) 

1056 elif cls is Tag: 

1057 raise NotTagError(ret.id) 

1058 else: 

1059 raise Exception(f"Type invalid: {ret.type_name!r} != {cls.type_name!r}") 

1060 return ret 

1061 

1062 def get_object(self, sha: ObjectID | RawObjectID) -> ShaFile: 

1063 """Retrieve the object with the specified SHA. 

1064 

1065 Args: 

1066 sha: SHA to retrieve 

1067 Returns: A ShaFile object 

1068 Raises: 

1069 KeyError: when the object can not be found 

1070 """ 

1071 return self.object_store[sha] 

1072 

1073 def parents_provider(self) -> ParentsProvider: 

1074 """Get a parents provider for this repository. 

1075 

1076 Returns: 

1077 ParentsProvider instance configured with grafts and shallows 

1078 """ 

1079 return ParentsProvider( 

1080 self.object_store, 

1081 grafts=self._graftpoints, 

1082 shallows=self.get_shallow(), 

1083 ) 

1084 

1085 def get_parents( 

1086 self, sha: ObjectID, commit: Commit | None = None 

1087 ) -> list[ObjectID]: 

1088 """Retrieve the parents of a specific commit. 

1089 

1090 If the specific commit is a graftpoint, the graft parents 

1091 will be returned instead. 

1092 

1093 Args: 

1094 sha: SHA of the commit for which to retrieve the parents 

1095 commit: Optional commit matching the sha 

1096 Returns: List of parents 

1097 """ 

1098 return self.parents_provider().get_parents(sha, commit) 

1099 

1100 def get_config(self) -> "ConfigFile": 

1101 """Retrieve the config object. 

1102 

1103 Returns: `ConfigFile` object for the ``.git/config`` file. 

1104 """ 

1105 raise NotImplementedError(self.get_config) 

1106 

1107 def get_worktree_config(self) -> "ConfigFile": 

1108 """Retrieve the worktree config object.""" 

1109 raise NotImplementedError(self.get_worktree_config) 

1110 

1111 def get_description(self) -> bytes | None: 

1112 """Retrieve the description for this repository. 

1113 

1114 Returns: Bytes with the description of the repository 

1115 as set by the user. 

1116 """ 

1117 raise NotImplementedError(self.get_description) 

1118 

1119 def set_description(self, description: bytes) -> None: 

1120 """Set the description for this repository. 

1121 

1122 Args: 

1123 description: Text to set as description for this repository. 

1124 """ 

1125 raise NotImplementedError(self.set_description) 

1126 

1127 def get_rebase_state_manager(self) -> "RebaseStateManager": 

1128 """Get the appropriate rebase state manager for this repository. 

1129 

1130 Returns: RebaseStateManager instance 

1131 """ 

1132 raise NotImplementedError(self.get_rebase_state_manager) 

1133 

1134 def get_blob_normalizer( 

1135 self, config: "Config | None" = None 

1136 ) -> "FilterBlobNormalizer": 

1137 """Return a BlobNormalizer object for checkin/checkout operations. 

1138 

1139 Args: 

1140 config: Configuration to consult for filter setup. If None, 

1141 implementations may fall back to ``self.get_config_stack()``. 

1142 

1143 Returns: BlobNormalizer instance 

1144 """ 

1145 raise NotImplementedError(self.get_blob_normalizer) 

1146 

1147 def get_gitattributes(self, tree: bytes | None = None) -> "GitAttributes": 

1148 """Read gitattributes for the repository. 

1149 

1150 Args: 

1151 tree: Tree SHA to read .gitattributes from (defaults to HEAD) 

1152 

1153 Returns: 

1154 GitAttributes object that can be used to match paths 

1155 """ 

1156 raise NotImplementedError(self.get_gitattributes) 

1157 

1158 def get_config_stack(self) -> "StackedConfig": 

1159 """Return a config stack for this repository. 

1160 

1161 This stack accesses the configuration for both this repository 

1162 itself (.git/config) and the global configuration, which usually 

1163 lives in ~/.gitconfig. 

1164 

1165 Returns: `Config` instance for this repository 

1166 """ 

1167 from .config import ConfigFile, StackedConfig 

1168 

1169 local_config = self.get_config() 

1170 backends: list[ConfigFile] = [local_config] 

1171 if local_config.get_boolean((b"extensions",), b"worktreeconfig", False): 

1172 backends.append(self.get_worktree_config()) 

1173 

1174 backends += StackedConfig.default_backends() 

1175 return StackedConfig(backends, writable=local_config) 

1176 

1177 def get_shallow(self) -> set[ObjectID]: 

1178 """Get the set of shallow commits. 

1179 

1180 Returns: Set of shallow commits. 

1181 """ 

1182 f = self.get_named_file("shallow") 

1183 if f is None: 

1184 return set() 

1185 with f: 

1186 shallow: set[ObjectID] = set() 

1187 for line in f: 

1188 sha = line.strip() 

1189 if not sha: 

1190 continue 

1191 check_hexsha(sha, "invalid shallow object id") 

1192 shallow.add(ObjectID(sha)) 

1193 return shallow 

1194 

1195 def update_shallow( 

1196 self, new_shallow: set[ObjectID] | None, new_unshallow: set[ObjectID] | None 

1197 ) -> None: 

1198 """Update the list of shallow objects. 

1199 

1200 Args: 

1201 new_shallow: Newly shallow objects 

1202 new_unshallow: Newly no longer shallow objects 

1203 """ 

1204 for sha in (*(new_shallow or ()), *(new_unshallow or ())): 

1205 check_hexsha(sha, "invalid shallow object id") 

1206 shallow = self.get_shallow() 

1207 if new_shallow: 

1208 shallow.update(new_shallow) 

1209 if new_unshallow: 

1210 shallow.difference_update(new_unshallow) 

1211 if shallow: 

1212 self._put_named_file("shallow", b"".join([sha + b"\n" for sha in shallow])) 

1213 else: 

1214 self._del_named_file("shallow") 

1215 

1216 def get_peeled(self, ref: Ref) -> ObjectID: 

1217 """Get the peeled value of a ref. 

1218 

1219 Args: 

1220 ref: The refname to peel. 

1221 Returns: The fully-peeled SHA1 of a tag object, after peeling all 

1222 intermediate tags; if the original ref does not point to a tag, 

1223 this will equal the original SHA1. 

1224 """ 

1225 cached = self.refs.get_peeled(ref) 

1226 if cached is not None: 

1227 return cached 

1228 return self.object_store.peel(self.refs[ref])[1].id 

1229 

1230 @property 

1231 def notes(self) -> "Notes": 

1232 """Access notes functionality for this repository. 

1233 

1234 Returns: 

1235 Notes object for accessing notes 

1236 """ 

1237 from .notes import Notes 

1238 

1239 return Notes(self.object_store, self.refs) 

1240 

1241 def get_walker( 

1242 self, 

1243 include: Sequence[ObjectID] | None = None, 

1244 exclude: Sequence[ObjectID] | None = None, 

1245 order: str = "date", 

1246 reverse: bool = False, 

1247 max_entries: int | None = None, 

1248 paths: Sequence[bytes] | None = None, 

1249 rename_detector: "RenameDetector | None" = None, 

1250 follow: bool = False, 

1251 since: int | None = None, 

1252 until: int | None = None, 

1253 queue_cls: type | None = None, 

1254 simplify_history: bool = False, 

1255 ) -> "Walker": 

1256 """Obtain a walker for this repository. 

1257 

1258 Args: 

1259 include: Iterable of SHAs of commits to include along with their 

1260 ancestors. Defaults to [HEAD] 

1261 exclude: Iterable of SHAs of commits to exclude along with their 

1262 ancestors, overriding includes. 

1263 order: ORDER_* constant specifying the order of results. 

1264 Anything other than ORDER_DATE may result in O(n) memory usage. 

1265 reverse: If True, reverse the order of output, requiring O(n) 

1266 memory. 

1267 max_entries: The maximum number of entries to yield, or None for 

1268 no limit. 

1269 paths: Iterable of file or subtree paths to show entries for. 

1270 rename_detector: diff.RenameDetector object for detecting 

1271 renames. 

1272 follow: If True, follow path across renames/copies. Forces a 

1273 default rename_detector. 

1274 since: Timestamp to list commits after. 

1275 until: Timestamp to list commits before. 

1276 queue_cls: A class to use for a queue of commits, supporting the 

1277 iterator protocol. The constructor takes a single argument, the Walker. 

1278 simplify_history: If True and paths is set, perform git-style 

1279 history simplification: at a merge commit that is TREESAME to 

1280 at least one parent for the requested paths, follow only that 

1281 parent (matches ``git log <path>``). The default of False 

1282 matches ``git log --full-history <path>``. 

1283 

1284 Returns: A `Walker` object 

1285 """ 

1286 from .walk import Walker, _CommitTimeQueue 

1287 

1288 if include is None: 

1289 include = [self.head()] 

1290 

1291 # Pass all arguments to Walker explicitly to avoid type issues with **kwargs 

1292 return Walker( 

1293 self.object_store, 

1294 include, 

1295 exclude=exclude, 

1296 order=order, 

1297 reverse=reverse, 

1298 max_entries=max_entries, 

1299 paths=paths, 

1300 rename_detector=rename_detector, 

1301 follow=follow, 

1302 since=since, 

1303 until=until, 

1304 get_parents=lambda commit: self.get_parents(commit.id, commit), 

1305 queue_cls=queue_cls if queue_cls is not None else _CommitTimeQueue, 

1306 simplify_history=simplify_history, 

1307 ) 

1308 

1309 def __getitem__(self, name: ObjectID | Ref | bytes) -> "ShaFile": 

1310 """Retrieve a Git object by SHA1 or ref. 

1311 

1312 Args: 

1313 name: A Git object SHA1 or a ref name 

1314 Returns: A `ShaFile` object, such as a Commit or Blob 

1315 Raises: 

1316 KeyError: when the specified ref or object does not exist 

1317 """ 

1318 if not isinstance(name, bytes): 

1319 raise TypeError(f"'name' must be bytestring, not {type(name).__name__:.80}") 

1320 # If it looks like a ref name, only try refs 

1321 if name == b"HEAD" or name.startswith(b"refs/"): 

1322 try: 

1323 return self.object_store[self.refs[Ref(name)]] 

1324 except (RefFormatError, KeyError): 

1325 pass 

1326 # Otherwise, try as object ID if length matches 

1327 if len(name) in ( 

1328 self.object_store.object_format.oid_length, 

1329 self.object_store.object_format.hex_length, 

1330 ): 

1331 try: 

1332 return self.object_store[ 

1333 ObjectID(name) 

1334 if len(name) == self.object_store.object_format.hex_length 

1335 else RawObjectID(name) 

1336 ] 

1337 except (KeyError, ValueError): 

1338 pass 

1339 # If nothing worked, raise KeyError 

1340 raise KeyError(name) 

1341 

1342 def __contains__(self, name: bytes) -> bool: 

1343 """Check if a specific Git object or ref is present. 

1344 

1345 Args: 

1346 name: Git object SHA1/SHA256 or ref name 

1347 """ 

1348 # Check if it's a binary or hex SHA 

1349 if len(name) == self.object_format.hex_length and valid_hexsha(name): 

1350 return ObjectID(name) in self.object_store or Ref(name) in self.refs 

1351 return Ref(name) in self.refs 

1352 

1353 def __setitem__(self, name: bytes, value: ShaFile | bytes) -> None: 

1354 """Set a ref. 

1355 

1356 Args: 

1357 name: ref name 

1358 value: Ref value - either a ShaFile object, or a hex sha 

1359 """ 

1360 if name.startswith(b"refs/") or name == HEADREF: 

1361 ref_name = Ref(name) 

1362 if isinstance(value, ShaFile): 

1363 self.refs[ref_name] = value.id 

1364 elif isinstance(value, bytes): 

1365 self.refs[ref_name] = ObjectID(value) 

1366 else: 

1367 raise TypeError(value) 

1368 else: 

1369 raise ValueError(name) 

1370 

1371 def __delitem__(self, name: bytes) -> None: 

1372 """Remove a ref. 

1373 

1374 Args: 

1375 name: Name of the ref to remove 

1376 """ 

1377 if name.startswith(b"refs/") or name == HEADREF: 

1378 del self.refs[Ref(name)] 

1379 else: 

1380 raise ValueError(name) 

1381 

1382 def _get_user_identity( 

1383 self, config: "StackedConfig", kind: str | None = None 

1384 ) -> bytes: 

1385 """Determine the identity to use for new commits.""" 

1386 warnings.warn( 

1387 "use get_user_identity() rather than Repo._get_user_identity", 

1388 DeprecationWarning, 

1389 ) 

1390 return get_user_identity(config) 

1391 

1392 def _add_graftpoints( 

1393 self, updated_graftpoints: dict[ObjectID, list[ObjectID]] 

1394 ) -> None: 

1395 """Add or modify graftpoints. 

1396 

1397 Args: 

1398 updated_graftpoints: Dict of commit shas to list of parent shas 

1399 """ 

1400 # Simple validation 

1401 for commit, parents in updated_graftpoints.items(): 

1402 for sha in [commit, *parents]: 

1403 check_hexsha(sha, "Invalid graftpoint") 

1404 

1405 self._graftpoints.update(updated_graftpoints) 

1406 

1407 def _remove_graftpoints(self, to_remove: Sequence[ObjectID] = ()) -> None: 

1408 """Remove graftpoints. 

1409 

1410 Args: 

1411 to_remove: List of commit shas 

1412 """ 

1413 for sha in to_remove: 

1414 del self._graftpoints[sha] 

1415 

1416 def _read_heads(self, name: str) -> list[ObjectID]: 

1417 f = self.get_named_file(name) 

1418 if f is None: 

1419 return [] 

1420 with f: 

1421 return [ObjectID(line.strip()) for line in f.readlines() if line.strip()] 

1422 

1423 def get_worktree(self) -> "WorkTree": 

1424 """Get the working tree for this repository. 

1425 

1426 Returns: 

1427 WorkTree instance for performing working tree operations 

1428 

1429 Raises: 

1430 NotImplementedError: If the repository doesn't support working trees 

1431 """ 

1432 raise NotImplementedError( 

1433 "Working tree operations not supported by this repository type" 

1434 ) 

1435 

1436 

1437def read_gitfile(f: BinaryIO) -> str: 

1438 """Read a ``.git`` file. 

1439 

1440 The first line of the file should start with "gitdir: " 

1441 

1442 Args: 

1443 f: File-like object to read from 

1444 Returns: A path 

1445 """ 

1446 cs = f.read() 

1447 if not cs.startswith(b"gitdir: "): 

1448 raise ValueError("Expected file to start with 'gitdir: '") 

1449 return cs[len(b"gitdir: ") :].rstrip(b"\r\n").decode("utf-8") 

1450 

1451 

1452class UnsupportedVersion(Exception): 

1453 """Unsupported repository version.""" 

1454 

1455 def __init__(self, version: int) -> None: 

1456 """Initialize UnsupportedVersion exception. 

1457 

1458 Args: 

1459 version: The unsupported repository version 

1460 """ 

1461 self.version = version 

1462 

1463 

1464class UnsupportedExtension(Exception): 

1465 """Unsupported repository extension.""" 

1466 

1467 def __init__(self, extension: str) -> None: 

1468 """Initialize UnsupportedExtension exception. 

1469 

1470 Args: 

1471 extension: The unsupported repository extension 

1472 """ 

1473 self.extension = extension 

1474 

1475 

1476class InvalidWorktreeConfiguration(Exception): 

1477 """core.worktree is set on a bare repository.""" 

1478 

1479 

1480class Repo(BaseRepo): 

1481 """A git repository backed by local disk. 

1482 

1483 To open an existing repository, call the constructor with 

1484 the path of the repository. 

1485 

1486 To create a new repository, use the Repo.init class method. 

1487 

1488 Note that a repository object may hold on to resources such 

1489 as file handles for performance reasons; call .close() to free 

1490 up those resources. 

1491 

1492 Attributes: 

1493 path: Path to the working copy (if it exists) or repository control 

1494 directory (if the repository is bare). ``core.worktree`` overrides 

1495 this, so it is not necessarily the parent of the control directory. 

1496 bare: Whether this is a bare repository 

1497 """ 

1498 

1499 path: str 

1500 bare: bool 

1501 object_store: DiskObjectStore 

1502 filter_context: "FilterContext | None" 

1503 _index_file_override: "str | None" 

1504 

1505 def __init__( 

1506 self, 

1507 root: str | bytes | os.PathLike[str] | None = None, 

1508 object_store: PackBasedObjectStore | None = None, 

1509 bare: bool | None = None, 

1510 *, 

1511 controldir: str | bytes | os.PathLike[str] | None = None, 

1512 commondir: str | bytes | os.PathLike[str] | None = None, 

1513 worktree: str | bytes | os.PathLike[str] | None = None, 

1514 object_directory: str | bytes | os.PathLike[str] | None = None, 

1515 alternates: "Iterable[str | os.PathLike[str]] | None" = None, 

1516 index_file: str | bytes | os.PathLike[str] | None = None, 

1517 ) -> None: 

1518 """Open a repository on disk. 

1519 

1520 Args: 

1521 root: Path to the repository's root. Optional if ``controldir`` is 

1522 given, in which case ``self.path`` defaults to ``worktree`` when 

1523 provided and to ``controldir`` otherwise. ``core.worktree`` in 

1524 config may still override it. 

1525 object_store: ObjectStore to use; if omitted, we use the 

1526 repository's default object store 

1527 bare: True if this is a bare repository. 

1528 controldir: Explicit path to the control directory (analogous to 

1529 ``GIT_DIR``). When set, discovery based on ``root`` is skipped. 

1530 commondir: Explicit path to the common directory (analogous to 

1531 ``GIT_COMMON_DIR``). Relative paths are resolved against the 

1532 control directory. 

1533 worktree: Explicit worktree path (analogous to ``GIT_WORK_TREE``). 

1534 Forces the repository to be treated as non-bare. 

1535 object_directory: Explicit path to the primary object store 

1536 (analogous to ``GIT_OBJECT_DIRECTORY``). Overrides 

1537 ``<common_dir>/objects``. 

1538 alternates: Extra alternate object directory paths (analogous to 

1539 ``GIT_ALTERNATE_OBJECT_DIRECTORIES``). Appended to any listed 

1540 in ``objects/info/alternates``. 

1541 index_file: Path to the index file (analogous to 

1542 ``GIT_INDEX_FILE``). Overrides ``<controldir>/index``. 

1543 """ 

1544 if controldir is None: 

1545 if root is None: 

1546 raise TypeError("Repo() requires either root or controldir") 

1547 root = os.fspath(root) 

1548 if isinstance(root, bytes): 

1549 root = os.fsdecode(root) 

1550 hidden_path = os.path.join(root, CONTROLDIR) 

1551 if worktree is not None: 

1552 bare = False 

1553 if bare is None: 

1554 if os.path.isfile(hidden_path) or os.path.isdir( 

1555 os.path.join(hidden_path, OBJECTDIR) 

1556 ): 

1557 bare = False 

1558 elif os.path.isdir(os.path.join(root, OBJECTDIR)) and os.path.isdir( 

1559 os.path.join(root, REFSDIR) 

1560 ): 

1561 bare = True 

1562 else: 

1563 raise NotGitRepository( 

1564 "No git repository was found at {path}".format( 

1565 **dict(path=root) 

1566 ) 

1567 ) 

1568 

1569 self.bare = bare 

1570 if bare is False: 

1571 if os.path.isfile(hidden_path): 

1572 with open(hidden_path, "rb") as f: 

1573 gitfile_path = read_gitfile(f) 

1574 self._controldir = os.path.join(root, gitfile_path) 

1575 else: 

1576 self._controldir = hidden_path 

1577 else: 

1578 self._controldir = root 

1579 else: 

1580 controldir = os.fspath(controldir) 

1581 if isinstance(controldir, bytes): 

1582 controldir = os.fsdecode(controldir) 

1583 self._controldir = controldir 

1584 if root is None: 

1585 # Without an explicit root, default to the worktree if given, 

1586 # else to the control dir (bare layout). We deliberately do 

1587 # not fall back to os.getcwd() here: a library-level ``Repo`` 

1588 # should not silently latch onto the current directory. 

1589 # Callers that want git's ``GIT_DIR``-implies-cwd-worktree 

1590 # semantics pass ``worktree`` explicitly. 

1591 root = os.fspath(worktree) if worktree is not None else controldir 

1592 else: 

1593 root = os.fspath(root) 

1594 if isinstance(root, bytes): 

1595 root = os.fsdecode(root) 

1596 if worktree is not None: 

1597 bare = False 

1598 if bare is None: 

1599 # With an explicit control dir and no worktree override, 

1600 # default to bare. core.bare / core.worktree parsed from 

1601 # config below may still flip this. 

1602 bare = True 

1603 self.bare = bare 

1604 if commondir is not None: 

1605 commondir_path = os.fspath(commondir) 

1606 if isinstance(commondir_path, bytes): 

1607 commondir_path = os.fsdecode(commondir_path) 

1608 if not os.path.isabs(commondir_path): 

1609 commondir_path = os.path.join(self._controldir, commondir_path) 

1610 self._commondir = commondir_path 

1611 else: 

1612 commondir_file = self.get_named_file(COMMONDIR) 

1613 if commondir_file is not None: 

1614 with commondir_file: 

1615 self._commondir = os.path.join( 

1616 self.controldir(), 

1617 os.fsdecode(commondir_file.read().rstrip(b"\r\n")), 

1618 ) 

1619 else: 

1620 self._commondir = self._controldir 

1621 self.path = root 

1622 if worktree is not None: 

1623 worktree_path = os.fspath(worktree) 

1624 if isinstance(worktree_path, bytes): 

1625 worktree_path = os.fsdecode(worktree_path) 

1626 self.path = worktree_path 

1627 

1628 if index_file is not None: 

1629 index_file_str = os.fspath(index_file) 

1630 if isinstance(index_file_str, bytes): 

1631 index_file_str = os.fsdecode(index_file_str) 

1632 self._index_file_override = index_file_str 

1633 else: 

1634 self._index_file_override = None 

1635 

1636 # Initialize refs early so they're available for config condition matchers 

1637 self.refs = DiskRefsContainer( 

1638 self.commondir(), self._controldir, logger=self._write_reflog 

1639 ) 

1640 

1641 # Initialize worktrees container 

1642 from .worktree import WorkTreeContainer 

1643 

1644 self.worktrees = WorkTreeContainer(self) 

1645 

1646 config = self.get_config() 

1647 try: 

1648 repository_format_version = config.get("core", "repositoryformatversion") 

1649 format_version = ( 

1650 0 

1651 if repository_format_version is None 

1652 else int(repository_format_version) 

1653 ) 

1654 except KeyError: 

1655 format_version = 0 

1656 

1657 if format_version not in (0, 1): 

1658 raise UnsupportedVersion(format_version) 

1659 

1660 try: 

1661 configured_worktree = config.get((b"core",), b"worktree") 

1662 except KeyError: 

1663 configured_worktree = None 

1664 if configured_worktree is not None: 

1665 # A repository is bare because core.bare says so, not because it 

1666 # was opened at its control directory: a submodule or a 

1667 # --separate-git-dir repository is opened that way but still has a 

1668 # working tree, named here. 

1669 if config.get_boolean((b"core",), b"bare", False): 

1670 raise InvalidWorktreeConfiguration( 

1671 "core.bare and core.worktree are incompatible" 

1672 ) 

1673 self.bare = False 

1674 # An explicit worktree argument takes precedence over core.worktree. 

1675 if worktree is None: 

1676 # Relative paths are resolved against the control directory, 

1677 # not against the current directory or the repository root. 

1678 self.path = os.path.join( 

1679 self._controldir, os.fsdecode(configured_worktree) 

1680 ) 

1681 

1682 # Track extensions we encounter 

1683 has_reftable_extension = False 

1684 for extension, value in config.items((b"extensions",)): 

1685 if extension.lower() == b"refstorage": 

1686 if value == b"reftable": 

1687 has_reftable_extension = True 

1688 else: 

1689 raise UnsupportedExtension(f"refStorage = {value.decode()}") 

1690 elif extension.lower() not in ( 

1691 b"worktreeconfig", 

1692 b"objectformat", 

1693 b"relativeworktrees", 

1694 ): 

1695 raise UnsupportedExtension(extension.decode("utf-8")) 

1696 

1697 if object_store is None: 

1698 # Get shared repository permissions from config 

1699 try: 

1700 shared_value = config.get(("core",), "sharedRepository") 

1701 shared_perm = parse_shared_repository(shared_value) 

1702 except KeyError: 

1703 shared_perm = None 

1704 

1705 if object_directory is not None: 

1706 object_dir_path = os.fspath(object_directory) 

1707 if isinstance(object_dir_path, bytes): 

1708 object_dir_path = os.fsdecode(object_dir_path) 

1709 else: 

1710 object_dir_path = os.path.join(self.commondir(), OBJECTDIR) 

1711 object_store = DiskObjectStore.from_config( 

1712 object_dir_path, 

1713 config, 

1714 shared_perm=shared_perm, 

1715 alternates=alternates, 

1716 ) 

1717 

1718 # Use reftable if extension is configured 

1719 if has_reftable_extension: 

1720 from .reftable import ReftableRefsContainer 

1721 

1722 self.refs = ReftableRefsContainer(self.commondir()) 

1723 # Update worktrees container after refs change 

1724 self.worktrees = WorkTreeContainer(self) 

1725 BaseRepo.__init__(self, object_store, self.refs) 

1726 

1727 # Determine hash algorithm from config if not already set 

1728 if self.object_format is None: 

1729 from .object_format import DEFAULT_OBJECT_FORMAT, get_object_format 

1730 

1731 if format_version == 1: 

1732 try: 

1733 object_format = config.get((b"extensions",), b"objectformat") 

1734 self.object_format = get_object_format( 

1735 object_format.decode("ascii") 

1736 ) 

1737 except KeyError: 

1738 self.object_format = DEFAULT_OBJECT_FORMAT 

1739 else: 

1740 self.object_format = DEFAULT_OBJECT_FORMAT 

1741 

1742 self._graftpoints = {} 

1743 graft_file = self.get_named_file( 

1744 os.path.join("info", "grafts"), basedir=self.commondir() 

1745 ) 

1746 if graft_file: 

1747 with graft_file: 

1748 self._graftpoints.update(parse_graftpoints(graft_file)) 

1749 graft_file = self.get_named_file("shallow", basedir=self.commondir()) 

1750 if graft_file: 

1751 with graft_file: 

1752 self._graftpoints.update(parse_graftpoints(graft_file)) 

1753 

1754 self.hooks["pre-commit"] = PreCommitShellHook(self.path, self.controldir()) 

1755 self.hooks["commit-msg"] = CommitMsgShellHook(self.controldir()) 

1756 self.hooks["post-commit"] = PostCommitShellHook(self.controldir()) 

1757 self.hooks["pre-receive"] = PreReceiveShellHook(self.controldir()) 

1758 self.hooks["update"] = UpdateShellHook(self.controldir()) 

1759 self.hooks["post-receive"] = PostReceiveShellHook(self.controldir()) 

1760 

1761 # Initialize filter context as None, will be created lazily 

1762 self.filter_context = None 

1763 

1764 def get_worktree(self) -> "WorkTree": 

1765 """Get the working tree for this repository. 

1766 

1767 Returns: 

1768 WorkTree instance for performing working tree operations 

1769 """ 

1770 from .worktree import WorkTree 

1771 

1772 return WorkTree(self, self.path) 

1773 

1774 def _write_reflog( 

1775 self, 

1776 ref: bytes, 

1777 old_sha: bytes, 

1778 new_sha: bytes, 

1779 committer: bytes | None, 

1780 timestamp: int | None, 

1781 timezone: int | None, 

1782 message: bytes, 

1783 ) -> None: 

1784 from .reflog import format_reflog_line 

1785 

1786 path = self._reflog_path(ref) 

1787 

1788 # Get shared repository permissions 

1789 shared_perm = self._get_shared_repository_permissions() 

1790 

1791 # Create directory with appropriate permissions 

1792 parent_dir = os.path.dirname(path) 

1793 # Create directory tree, setting permissions on each level if needed 

1794 parts = [] 

1795 current = parent_dir 

1796 while current and not os.path.exists(current): 

1797 parts.append(current) 

1798 current = os.path.dirname(current) 

1799 parts.reverse() 

1800 for part in parts: 

1801 os.mkdir(part) 

1802 adjust_shared_perm(part, shared_perm) 

1803 if committer is None: 

1804 config = self.get_config_stack() 

1805 committer = get_user_identity(config) 

1806 check_user_identity(committer) 

1807 if timestamp is None: 

1808 timestamp = int(time.time()) 

1809 if timezone is None: 

1810 timezone = 0 # FIXME 

1811 with open(path, "ab") as f: 

1812 f.write( 

1813 format_reflog_line( 

1814 old_sha, new_sha, committer, timestamp, timezone, message 

1815 ) 

1816 + b"\n" 

1817 ) 

1818 

1819 # Always adjust, so that permissions are right even if the file 

1820 # already existed. 

1821 adjust_shared_perm(path, shared_perm) 

1822 

1823 def _reflog_path(self, ref: bytes) -> str: 

1824 if ref.startswith((b"main-worktree/", b"worktrees/")): 

1825 raise NotImplementedError(f"refs {ref.decode()} are not supported") 

1826 

1827 base = self.controldir() if is_per_worktree_ref(ref) else self.commondir() 

1828 return os.path.join(base, "logs", os.fsdecode(ref)) 

1829 

1830 def read_reflog(self, ref: bytes) -> Generator[reflog.Entry, None, None]: 

1831 """Read reflog entries for a reference. 

1832 

1833 Args: 

1834 ref: Reference name (e.g. b'HEAD', b'refs/heads/master') 

1835 

1836 Yields: 

1837 reflog.Entry objects in chronological order (oldest first) 

1838 """ 

1839 from .reflog import read_reflog 

1840 

1841 path = self._reflog_path(ref) 

1842 try: 

1843 with open(path, "rb") as f: 

1844 yield from read_reflog(f) 

1845 except FileNotFoundError: 

1846 return 

1847 

1848 @classmethod 

1849 def discover( 

1850 cls, 

1851 start: str | bytes | os.PathLike[str] = ".", 

1852 *, 

1853 ceiling_dirs: "Iterable[str | os.PathLike[str]] | None" = None, 

1854 across_filesystem: bool = True, 

1855 ) -> "Repo": 

1856 """Iterate parent directories to discover a repository. 

1857 

1858 Return a Repo object for the first parent directory that looks like a 

1859 Git repository. 

1860 

1861 Args: 

1862 start: The directory to start discovery from (defaults to '.') 

1863 ceiling_dirs: Iterable of paths that discovery must not cross 

1864 (analogous to ``GIT_CEILING_DIRECTORIES``). The ceiling 

1865 directories themselves are not searched. As in git, a ceiling 

1866 matching ``start`` itself is ignored. Entries are resolved the 

1867 same way as the walking path, so passing either a symlinked or 

1868 a resolved form works. 

1869 across_filesystem: Whether to keep walking up past a filesystem 

1870 boundary (analogous to ``GIT_DISCOVERY_ACROSS_FILESYSTEM``). 

1871 When False, discovery stops before entering a parent directory 

1872 that lives on a different device than ``start``. 

1873 """ 

1874 # Both sides of the ceiling comparison are resolved so that they are 

1875 # in the same form: os.getcwd() already returns a resolved path on 

1876 # POSIX, and on Windows realpath() expands 8.3 short names that 

1877 # abspath() leaves alone. This also mirrors git, which walks up from 

1878 # the kernel-resolved cwd. 

1879 ceilings = ( 

1880 { 

1881 os.path.normcase(os.path.realpath(os.fsdecode(os.fspath(p)))) 

1882 for p in ceiling_dirs 

1883 } 

1884 if ceiling_dirs is not None 

1885 else set() 

1886 ) 

1887 path = os.path.realpath(start) 

1888 # Device of the starting directory, only tracked when we must not 

1889 # cross filesystem boundaries. Errors stat'ing it propagate: if the 

1890 # start directory is unreadable, discovery from it is meaningless. 

1891 start_dev = None if across_filesystem else os.stat(path).st_dev 

1892 first = True 

1893 while True: 

1894 if not first and os.path.normcase(path) in ceilings: 

1895 break 

1896 first = False 

1897 try: 

1898 return cls(path) 

1899 except NotGitRepository: 

1900 new_path, _tail = os.path.split(path) 

1901 if new_path == path: # Root reached 

1902 break 

1903 if start_dev is not None: 

1904 try: 

1905 parent_dev = os.stat(new_path).st_dev 

1906 except FileNotFoundError: 

1907 # Raced with the parent being removed; nothing left 

1908 # to walk up into. 

1909 break 

1910 if parent_dev != start_dev: 

1911 break 

1912 path = new_path 

1913 start_str = os.fspath(start) 

1914 if isinstance(start_str, bytes): 

1915 start_str = start_str.decode("utf-8") 

1916 raise NotGitRepository(f"No git repository was found at {start_str}") 

1917 

1918 def controldir(self) -> str: 

1919 """Return the path of the control directory.""" 

1920 return self._controldir 

1921 

1922 def commondir(self) -> str: 

1923 """Return the path of the common directory. 

1924 

1925 For a main working tree, it is identical to controldir(). 

1926 

1927 For a linked working tree, it is the control directory of the 

1928 main working tree. 

1929 """ 

1930 return self._commondir 

1931 

1932 def _determine_file_mode(self) -> bool: 

1933 """Probe the file-system to determine whether permissions can be trusted. 

1934 

1935 Returns: True if permissions can be trusted, False otherwise. 

1936 """ 

1937 fname = os.path.join(self.path, ".probe-permissions") 

1938 with open(fname, "w") as f: 

1939 f.write("") 

1940 

1941 st1 = os.lstat(fname) 

1942 try: 

1943 os.chmod(fname, st1.st_mode ^ stat.S_IXUSR) 

1944 except PermissionError: 

1945 return False 

1946 st2 = os.lstat(fname) 

1947 

1948 os.unlink(fname) 

1949 

1950 mode_differs = st1.st_mode != st2.st_mode 

1951 st2_has_exec = (st2.st_mode & stat.S_IXUSR) != 0 

1952 

1953 return mode_differs and st2_has_exec 

1954 

1955 def _determine_symlinks(self) -> bool: 

1956 """Probe the filesystem to determine whether symlinks can be created. 

1957 

1958 Returns: True if symlinks can be created, False otherwise. 

1959 """ 

1960 # TODO(jelmer): Actually probe disk / look at filesystem 

1961 return sys.platform != "win32" 

1962 

1963 def _get_shared_repository_permissions( 

1964 self, 

1965 ) -> "SharedPerm | None": 

1966 """Get the shared repository permission setting from config. 

1967 

1968 Returns: 

1969 SharedPerm to apply, or None if not shared 

1970 """ 

1971 try: 

1972 config = self.get_config() 

1973 value = config.get(("core",), "sharedRepository") 

1974 return parse_shared_repository(value) 

1975 except KeyError: 

1976 return None 

1977 

1978 def _put_named_file(self, path: str, contents: bytes) -> None: 

1979 """Write a file to the control dir with the given name and contents. 

1980 

1981 Args: 

1982 path: The path to the file, relative to the control dir. 

1983 contents: A string to write to the file. 

1984 """ 

1985 path = path.lstrip(os.path.sep) 

1986 

1987 # Get shared repository permissions 

1988 shared_perm = self._get_shared_repository_permissions() 

1989 

1990 # Create file with appropriate permissions 

1991 full_path = os.path.join(self.controldir(), path) 

1992 if shared_perm is not None: 

1993 with GitFile(full_path, "wb", shared_perm=shared_perm) as f: 

1994 f.write(contents) 

1995 else: 

1996 with GitFile(full_path, "wb") as f: 

1997 f.write(contents) 

1998 

1999 def _del_named_file(self, path: str) -> None: 

2000 try: 

2001 os.unlink(os.path.join(self.controldir(), path)) 

2002 except FileNotFoundError: 

2003 return 

2004 

2005 def get_named_file( 

2006 self, 

2007 path: str | bytes, 

2008 basedir: str | None = None, 

2009 ) -> BinaryIO | None: 

2010 """Get a file from the control dir with a specific name. 

2011 

2012 Although the filename should be interpreted as a filename relative to 

2013 the control dir in a disk-based Repo, the object returned need not be 

2014 pointing to a file in that location. 

2015 

2016 Args: 

2017 path: The path to the file, relative to the control dir. 

2018 basedir: Optional argument that specifies an alternative to the 

2019 control dir. 

2020 Returns: An open file object, or None if the file does not exist. 

2021 """ 

2022 # TODO(dborowitz): sanitize filenames, since this is used directly by 

2023 # the dumb web serving code. 

2024 if basedir is None: 

2025 basedir = self.controldir() 

2026 if isinstance(path, bytes): 

2027 path = path.decode("utf-8") 

2028 path = path.lstrip(os.path.sep) 

2029 try: 

2030 return open(os.path.join(basedir, path), "rb") 

2031 except FileNotFoundError: 

2032 return None 

2033 

2034 def index_path(self) -> str: 

2035 """Return path to the index file.""" 

2036 if self._index_file_override is not None: 

2037 return self._index_file_override 

2038 return os.path.join(self.controldir(), INDEX_FILENAME) 

2039 

2040 def open_index(self, config: "Config | None" = None) -> "Index": 

2041 """Open the index for this repository. 

2042 

2043 Args: 

2044 config: Configuration to consult for index settings. If None, 

2045 falls back to ``self.get_config_stack()``. 

2046 

2047 Raises: 

2048 NoIndexPresent: If no index is present 

2049 Returns: The matching `Index` 

2050 """ 

2051 from .index import Index, make_path_normalizer 

2052 

2053 if not self.has_index(): 

2054 raise NoIndexPresent 

2055 

2056 if config is None: 

2057 config = self.get_config_stack() 

2058 many_files = config.get_boolean(b"feature", b"manyFiles", False) 

2059 skip_hash = False 

2060 index_version = None 

2061 

2062 if many_files: 

2063 # When feature.manyFiles is enabled, set index.version=4 and index.skipHash=true 

2064 try: 

2065 index_version_str = config.get(b"index", b"version") 

2066 index_version = int(index_version_str) 

2067 except KeyError: 

2068 index_version = 4 # Default to version 4 for manyFiles 

2069 skip_hash = config.get_boolean(b"index", b"skipHash", True) 

2070 else: 

2071 # Check for explicit index settings 

2072 try: 

2073 index_version_str = config.get(b"index", b"version") 

2074 index_version = int(index_version_str) 

2075 except KeyError: 

2076 index_version = None 

2077 skip_hash = config.get_boolean(b"index", b"skipHash", False) 

2078 

2079 # Get shared repository permissions for index file 

2080 shared_perm = self._get_shared_repository_permissions() 

2081 

2082 return Index( 

2083 self.index_path(), 

2084 skip_hash=skip_hash, 

2085 version=index_version, 

2086 shared_perm=shared_perm, 

2087 path_normalizer=make_path_normalizer(config), 

2088 object_format=self.object_format, 

2089 ) 

2090 

2091 def has_index(self) -> bool: 

2092 """Check if an index is present.""" 

2093 # Bare repos must never have index files; non-bare repos may have a 

2094 # missing index file, which is treated as empty. 

2095 return not self.bare 

2096 

2097 def clone( 

2098 self, 

2099 target_path: str | bytes | os.PathLike[str], 

2100 *, 

2101 mkdir: bool = True, 

2102 bare: bool = False, 

2103 origin: bytes = b"origin", 

2104 checkout: bool | None = None, 

2105 branch: bytes | None = None, 

2106 progress: Callable[[str], None] | None = None, 

2107 depth: int | None = None, 

2108 symlinks: bool | None = None, 

2109 ) -> "Repo": 

2110 """Clone this repository. 

2111 

2112 Args: 

2113 target_path: Target path 

2114 mkdir: Create the target directory 

2115 bare: Whether to create a bare repository 

2116 checkout: Whether or not to check-out HEAD after cloning 

2117 origin: Base name for refs in target repository 

2118 cloned from this repository 

2119 branch: Optional branch or tag to be used as HEAD in the new repository 

2120 instead of this repository's HEAD. 

2121 progress: Optional progress function 

2122 depth: Depth at which to fetch 

2123 symlinks: Symlinks setting (default to autodetect) 

2124 Returns: Created repository as `Repo` 

2125 """ 

2126 encoded_path = os.fsencode(self.path) 

2127 

2128 if mkdir: 

2129 os.mkdir(target_path) 

2130 

2131 try: 

2132 if not bare: 

2133 target = Repo.init(target_path, symlinks=symlinks) 

2134 if checkout is None: 

2135 checkout = True 

2136 else: 

2137 if checkout: 

2138 raise ValueError("checkout and bare are incompatible") 

2139 target = Repo.init_bare(target_path) 

2140 

2141 try: 

2142 target_config = target.get_config() 

2143 target_config.set((b"remote", origin), b"url", encoded_path) 

2144 target_config.set( 

2145 (b"remote", origin), 

2146 b"fetch", 

2147 b"+refs/heads/*:refs/remotes/" + origin + b"/*", 

2148 ) 

2149 target_config.write_to_path() 

2150 

2151 ref_message = b"clone: from " + encoded_path 

2152 self.fetch(target, depth=depth) 

2153 target.refs.import_refs( 

2154 Ref(b"refs/remotes/" + origin), 

2155 self.refs.as_dict(Ref(b"refs/heads")), 

2156 message=ref_message, 

2157 ) 

2158 target.refs.import_refs( 

2159 Ref(b"refs/tags"), 

2160 self.refs.as_dict(Ref(b"refs/tags")), 

2161 message=ref_message, 

2162 ) 

2163 

2164 head_chain, origin_sha = self.refs.follow(HEADREF) 

2165 origin_head = head_chain[-1] if head_chain else None 

2166 head: ObjectID | None = None 

2167 if origin_sha and not origin_head: 

2168 # set detached HEAD 

2169 target.refs[HEADREF] = origin_sha 

2170 head = origin_sha 

2171 else: 

2172 _set_origin_head(target.refs, origin, origin_head) 

2173 head_ref = _set_default_branch( 

2174 target.refs, origin, origin_head, branch, ref_message 

2175 ) 

2176 

2177 # Update target head 

2178 if head_ref: 

2179 head = _set_head(target.refs, head_ref, ref_message) 

2180 if not bare: 

2181 _set_branch_tracking(target.get_config(), head_ref, origin) 

2182 else: 

2183 head = None 

2184 

2185 if checkout and head is not None: 

2186 target.get_worktree().reset_index(config=target.get_config_stack()) 

2187 except BaseException: 

2188 target.close() 

2189 raise 

2190 except BaseException: 

2191 if mkdir: 

2192 import shutil 

2193 

2194 shutil.rmtree(target_path) 

2195 raise 

2196 return target 

2197 

2198 def _get_config_condition_matchers(self) -> dict[str, "ConditionMatcher"]: 

2199 """Get condition matchers for includeIf conditions. 

2200 

2201 Returns a dict of condition prefix to matcher function. 

2202 """ 

2203 from pathlib import Path 

2204 

2205 from .config import ConditionMatcher, match_glob_pattern 

2206 

2207 # Add gitdir matchers 

2208 def match_gitdir(pattern: str, case_sensitive: bool = True) -> bool: 

2209 """Match gitdir against a pattern. 

2210 

2211 Args: 

2212 pattern: Pattern to match against 

2213 case_sensitive: Whether to match case-sensitively 

2214 

2215 Returns: 

2216 True if gitdir matches pattern 

2217 """ 

2218 # Handle relative patterns (starting with ./) 

2219 if pattern.startswith("./"): 

2220 # Can't handle relative patterns without config directory context 

2221 return False 

2222 

2223 # Normalize repository path 

2224 try: 

2225 repo_path = str(Path(self._controldir).resolve()) 

2226 except (OSError, ValueError): 

2227 return False 

2228 

2229 # Expand ~ in pattern and normalize 

2230 pattern = os.path.expanduser(pattern) 

2231 

2232 # Normalize pattern following Git's rules 

2233 pattern = pattern.replace("\\", "/") 

2234 if not pattern.startswith(("~/", "./", "/", "**")): 

2235 # Check for Windows absolute path 

2236 if len(pattern) >= 2 and pattern[1] == ":": 

2237 pass 

2238 else: 

2239 pattern = "**/" + pattern 

2240 if pattern.endswith("/"): 

2241 pattern = pattern + "**" 

2242 

2243 # Use the existing _match_gitdir_pattern function 

2244 from .config import _match_gitdir_pattern 

2245 

2246 pattern_bytes = pattern.encode("utf-8", errors="replace") 

2247 repo_path_bytes = repo_path.encode("utf-8", errors="replace") 

2248 

2249 return _match_gitdir_pattern( 

2250 repo_path_bytes, pattern_bytes, ignorecase=not case_sensitive 

2251 ) 

2252 

2253 # Add onbranch matcher 

2254 def match_onbranch(pattern: str) -> bool: 

2255 """Match current branch against a pattern. 

2256 

2257 Args: 

2258 pattern: Pattern to match against 

2259 

2260 Returns: 

2261 True if current branch matches pattern 

2262 """ 

2263 try: 

2264 # Get the current branch using refs 

2265 ref_chain, _ = self.refs.follow(HEADREF) 

2266 head_ref = ref_chain[-1] # Get the final resolved ref 

2267 except KeyError: 

2268 pass 

2269 else: 

2270 if head_ref and head_ref.startswith(b"refs/heads/"): 

2271 # Extract branch name from ref 

2272 branch = extract_branch_name(head_ref).decode( 

2273 "utf-8", errors="replace" 

2274 ) 

2275 return match_glob_pattern(branch, pattern) 

2276 return False 

2277 

2278 matchers: dict[str, ConditionMatcher] = { 

2279 "onbranch:": match_onbranch, 

2280 "gitdir:": lambda pattern: match_gitdir(pattern, True), 

2281 "gitdir/i:": lambda pattern: match_gitdir(pattern, False), 

2282 } 

2283 

2284 return matchers 

2285 

2286 def get_worktree_config(self) -> "ConfigFile": 

2287 """Get the worktree-specific config. 

2288 

2289 Returns: 

2290 ConfigFile object for the worktree config 

2291 """ 

2292 from .config import ConfigFile 

2293 

2294 path = os.path.join(self.commondir(), "config.worktree") 

2295 try: 

2296 # Pass condition matchers for includeIf evaluation 

2297 condition_matchers = self._get_config_condition_matchers() 

2298 return ConfigFile.from_path(path, condition_matchers=condition_matchers) 

2299 except FileNotFoundError: 

2300 cf = ConfigFile() 

2301 cf.path = path 

2302 return cf 

2303 

2304 def get_config(self) -> "ConfigFile": 

2305 """Retrieve the config object. 

2306 

2307 Returns: `ConfigFile` object for the ``.git/config`` file. 

2308 """ 

2309 from .config import ConfigFile 

2310 

2311 path = os.path.join(self._commondir, "config") 

2312 try: 

2313 # Pass condition matchers for includeIf evaluation 

2314 condition_matchers = self._get_config_condition_matchers() 

2315 return ConfigFile.from_path(path, condition_matchers=condition_matchers) 

2316 except FileNotFoundError: 

2317 ret = ConfigFile() 

2318 ret.path = path 

2319 return ret 

2320 

2321 def get_rebase_state_manager(self) -> "RebaseStateManager": 

2322 """Get the appropriate rebase state manager for this repository. 

2323 

2324 Returns: DiskRebaseStateManager instance 

2325 """ 

2326 import os 

2327 

2328 from .rebase import DiskRebaseStateManager 

2329 

2330 path = os.path.join(self.controldir(), "rebase-merge") 

2331 return DiskRebaseStateManager(path) 

2332 

2333 def get_description(self) -> bytes | None: 

2334 """Retrieve the description of this repository. 

2335 

2336 Returns: Description as bytes or None. 

2337 """ 

2338 path = os.path.join(self._controldir, "description") 

2339 try: 

2340 with GitFile(path, "rb") as f: 

2341 return f.read() 

2342 except FileNotFoundError: 

2343 return None 

2344 

2345 def __repr__(self) -> str: 

2346 """Return string representation of this repository.""" 

2347 return f"<Repo at {self.path!r}>" 

2348 

2349 def set_description(self, description: bytes) -> None: 

2350 """Set the description for this repository. 

2351 

2352 Args: 

2353 description: Text to set as description for this repository. 

2354 """ 

2355 self._put_named_file("description", description) 

2356 

2357 @classmethod 

2358 def _init_maybe_bare( 

2359 cls, 

2360 path: str | bytes | os.PathLike[str], 

2361 controldir: str | bytes | os.PathLike[str], 

2362 bare: bool, 

2363 object_store: PackBasedObjectStore | None = None, 

2364 config: "StackedConfig | None" = None, 

2365 default_branch: bytes | None = None, 

2366 symlinks: bool | None = None, 

2367 format: int | None = None, 

2368 shared_repository: str | bool | None = None, 

2369 object_format: str | None = None, 

2370 ) -> "Repo": 

2371 path = os.fspath(path) 

2372 if isinstance(path, bytes): 

2373 path = os.fsdecode(path) 

2374 controldir = os.fspath(controldir) 

2375 if isinstance(controldir, bytes): 

2376 controldir = os.fsdecode(controldir) 

2377 

2378 # Determine shared repository permissions early 

2379 shared_perm: SharedPerm | None = None 

2380 if shared_repository is not None: 

2381 shared_perm = parse_shared_repository(shared_repository) 

2382 

2383 # Create base directories with appropriate permissions 

2384 for d in BASE_DIRECTORIES: 

2385 dir_path = os.path.join(controldir, *d) 

2386 os.mkdir(dir_path) 

2387 adjust_shared_perm(dir_path, shared_perm) 

2388 

2389 # Determine hash algorithm 

2390 from .object_format import get_object_format 

2391 

2392 hash_alg = get_object_format(object_format) 

2393 

2394 if object_store is None: 

2395 object_store = DiskObjectStore.init( 

2396 os.path.join(controldir, OBJECTDIR), 

2397 shared_perm=shared_perm, 

2398 object_format=hash_alg, 

2399 ) 

2400 ret = cls(path, bare=bare, object_store=object_store) 

2401 if default_branch is None: 

2402 if config is None: 

2403 from .config import StackedConfig 

2404 

2405 config = StackedConfig.default() 

2406 try: 

2407 default_branch = config.get("init", "defaultBranch") 

2408 except KeyError: 

2409 default_branch = DEFAULT_BRANCH 

2410 ret.refs.set_symbolic_ref(HEADREF, local_branch_name(default_branch)) 

2411 ret._init_files( 

2412 bare=bare, 

2413 symlinks=symlinks, 

2414 format=format, 

2415 shared_repository=shared_repository, 

2416 object_format=object_format, 

2417 ) 

2418 return ret 

2419 

2420 @classmethod 

2421 def init( 

2422 cls, 

2423 path: str | bytes | os.PathLike[str], 

2424 *, 

2425 mkdir: bool = False, 

2426 config: "StackedConfig | None" = None, 

2427 default_branch: bytes | None = None, 

2428 symlinks: bool | None = None, 

2429 format: int | None = None, 

2430 shared_repository: str | bool | None = None, 

2431 object_format: str | None = None, 

2432 ) -> "Repo": 

2433 """Create a new repository. 

2434 

2435 Args: 

2436 path: Path in which to create the repository 

2437 mkdir: Whether to create the directory 

2438 config: Configuration object 

2439 default_branch: Default branch name 

2440 symlinks: Whether to support symlinks 

2441 format: Repository format version (defaults to 0) 

2442 shared_repository: Shared repository setting (group, all, umask, or octal) 

2443 object_format: Object format to use ("sha1" or "sha256", defaults to "sha1") 

2444 Returns: `Repo` instance 

2445 """ 

2446 path = os.fspath(path) 

2447 if isinstance(path, bytes): 

2448 path = os.fsdecode(path) 

2449 if mkdir: 

2450 os.mkdir(path) 

2451 controldir = os.path.join(path, CONTROLDIR) 

2452 os.mkdir(controldir) 

2453 _set_filesystem_hidden(controldir) 

2454 return cls._init_maybe_bare( 

2455 path, 

2456 controldir, 

2457 False, 

2458 config=config, 

2459 default_branch=default_branch, 

2460 symlinks=symlinks, 

2461 format=format, 

2462 shared_repository=shared_repository, 

2463 object_format=object_format, 

2464 ) 

2465 

2466 @classmethod 

2467 def _init_new_working_directory( 

2468 cls, 

2469 path: str | bytes | os.PathLike[str], 

2470 main_repo: "Repo", 

2471 identifier: str | None = None, 

2472 mkdir: bool = False, 

2473 relative_paths: bool = False, 

2474 ) -> "Repo": 

2475 """Create a new working directory linked to a repository. 

2476 

2477 Args: 

2478 path: Path in which to create the working tree. 

2479 main_repo: Main repository to reference 

2480 identifier: Worktree identifier 

2481 mkdir: Whether to create the directory 

2482 relative_paths: Whether to use relative paths for gitdir references 

2483 Returns: `Repo` instance 

2484 """ 

2485 path = os.fspath(path) 

2486 if isinstance(path, bytes): 

2487 path = os.fsdecode(path) 

2488 if mkdir: 

2489 os.mkdir(path) 

2490 if identifier is None: 

2491 identifier = os.path.basename(path) 

2492 # Ensure we use absolute path for the worktree control directory 

2493 main_controldir = os.path.abspath(main_repo.controldir()) 

2494 main_worktreesdir = os.path.join(main_controldir, WORKTREES) 

2495 worktree_controldir = os.path.join(main_worktreesdir, identifier) 

2496 gitdirfile_abs = os.path.abspath(os.path.join(path, CONTROLDIR)) 

2497 

2498 # Write gitdir reference in .git file (can be relative) 

2499 # Import helper from worktree module to avoid duplication 

2500 from .worktree import _compute_gitdir_path 

2501 

2502 gitdir_ref = _compute_gitdir_path( 

2503 main_repo, 

2504 worktree_controldir, 

2505 os.path.dirname(gitdirfile_abs), 

2506 relative_paths, 

2507 ) 

2508 

2509 with open(gitdirfile_abs, "wb") as f: 

2510 f.write(b"gitdir: " + os.fsencode(gitdir_ref) + b"\n") 

2511 

2512 # Get shared repository permissions from main repository 

2513 shared_perm = main_repo._get_shared_repository_permissions() 

2514 

2515 # Create directories with appropriate permissions 

2516 try: 

2517 os.mkdir(main_worktreesdir) 

2518 adjust_shared_perm(main_worktreesdir, shared_perm) 

2519 except FileExistsError: 

2520 pass 

2521 try: 

2522 os.mkdir(worktree_controldir) 

2523 adjust_shared_perm(worktree_controldir, shared_perm) 

2524 except FileExistsError: 

2525 pass 

2526 

2527 # Write gitdir path in control directory (can be relative) 

2528 gitdir_path = _compute_gitdir_path( 

2529 main_repo, gitdirfile_abs, worktree_controldir, relative_paths 

2530 ) 

2531 

2532 with open(os.path.join(worktree_controldir, GITDIR), "wb") as f: 

2533 f.write(os.fsencode(gitdir_path) + b"\n") 

2534 with open(os.path.join(worktree_controldir, COMMONDIR), "wb") as f: 

2535 f.write(b"../..\n") 

2536 with open(os.path.join(worktree_controldir, "HEAD"), "wb") as f: 

2537 f.write(main_repo.head() + b"\n") 

2538 r = cls(os.path.normpath(path)) 

2539 r.get_worktree().reset_index(config=r.get_config_stack()) 

2540 return r 

2541 

2542 @classmethod 

2543 def init_bare( 

2544 cls, 

2545 path: str | bytes | os.PathLike[str], 

2546 *, 

2547 mkdir: bool = False, 

2548 object_store: PackBasedObjectStore | None = None, 

2549 config: "StackedConfig | None" = None, 

2550 default_branch: bytes | None = None, 

2551 format: int | None = None, 

2552 shared_repository: str | bool | None = None, 

2553 object_format: str | None = None, 

2554 ) -> "Repo": 

2555 """Create a new bare repository. 

2556 

2557 ``path`` should already exist and be an empty directory. 

2558 

2559 Args: 

2560 path: Path to create bare repository in 

2561 mkdir: Whether to create the directory 

2562 object_store: Object store to use 

2563 config: Configuration object 

2564 default_branch: Default branch name 

2565 format: Repository format version (defaults to 0) 

2566 shared_repository: Shared repository setting (group, all, umask, or octal) 

2567 object_format: Object format to use ("sha1" or "sha256", defaults to "sha1") 

2568 Returns: a `Repo` instance 

2569 """ 

2570 path = os.fspath(path) 

2571 if isinstance(path, bytes): 

2572 path = os.fsdecode(path) 

2573 if mkdir: 

2574 os.mkdir(path) 

2575 return cls._init_maybe_bare( 

2576 path, 

2577 path, 

2578 True, 

2579 object_store=object_store, 

2580 config=config, 

2581 default_branch=default_branch, 

2582 format=format, 

2583 shared_repository=shared_repository, 

2584 object_format=object_format, 

2585 ) 

2586 

2587 create = init_bare 

2588 

2589 def close(self) -> None: 

2590 """Close any files opened by this repository.""" 

2591 self.object_store.close() 

2592 # Clean up filter context if it was created 

2593 if self.filter_context is not None: 

2594 self.filter_context.close() 

2595 self.filter_context = None 

2596 

2597 def __enter__(self) -> Self: 

2598 """Enter context manager.""" 

2599 return self 

2600 

2601 def __exit__( 

2602 self, 

2603 exc_type: type[BaseException] | None, 

2604 exc_val: BaseException | None, 

2605 exc_tb: TracebackType | None, 

2606 ) -> None: 

2607 """Exit context manager and close repository.""" 

2608 self.close() 

2609 

2610 def _read_gitattributes(self) -> dict[bytes, dict[bytes, bytes]]: 

2611 """Read .gitattributes file from working tree. 

2612 

2613 A ``.gitattributes`` that is a symlink is skipped with a warning 

2614 rather than followed, as git does. 

2615 

2616 Returns: 

2617 Dictionary mapping file patterns to attributes 

2618 """ 

2619 gitattributes: dict[bytes, dict[bytes, bytes]] = {} 

2620 gitattributes_path = os.path.join(self.path, ".gitattributes") 

2621 

2622 try: 

2623 f = open_nofollow_read(gitattributes_path) 

2624 except FileNotFoundError: 

2625 return gitattributes 

2626 except OSError as e: 

2627 if e.errno not in (errno.ELOOP, errno.EMLINK): 

2628 raise 

2629 logger.warning("Ignoring %s: it is a symbolic link", gitattributes_path) 

2630 return gitattributes 

2631 with f: 

2632 for line in f: 

2633 line = line.strip() 

2634 if not line or line.startswith(b"#"): 

2635 continue 

2636 

2637 parts = line.split() 

2638 if len(parts) < 2: 

2639 continue 

2640 

2641 pattern = parts[0] 

2642 attrs = {} 

2643 

2644 for attr in parts[1:]: 

2645 if attr.startswith(b"-"): 

2646 # Unset attribute 

2647 attrs[attr[1:]] = b"false" 

2648 elif b"=" in attr: 

2649 # Set to value 

2650 key, value = attr.split(b"=", 1) 

2651 attrs[key] = value 

2652 else: 

2653 # Set attribute 

2654 attrs[attr] = b"true" 

2655 

2656 gitattributes[pattern] = attrs 

2657 

2658 return gitattributes 

2659 

2660 def get_blob_normalizer( 

2661 self, config: "Config | None" = None 

2662 ) -> "FilterBlobNormalizer": 

2663 """Return a BlobNormalizer object. 

2664 

2665 Args: 

2666 config: Configuration to consult for filter setup. If None, 

2667 falls back to ``self.get_config_stack()``. 

2668 """ 

2669 from .filters import FilterBlobNormalizer, FilterContext, FilterRegistry 

2670 

2671 if config is None: 

2672 config = self.get_config_stack() 

2673 git_attributes = self.get_gitattributes() 

2674 

2675 # Lazily create FilterContext if needed 

2676 if self.filter_context is None: 

2677 filter_registry = FilterRegistry(config, self) 

2678 self.filter_context = FilterContext(filter_registry) 

2679 else: 

2680 # Refresh the context with current config to handle config changes 

2681 self.filter_context.refresh_config(config) 

2682 

2683 return FilterBlobNormalizer( 

2684 config, git_attributes, filter_context=self.filter_context 

2685 ) 

2686 

2687 def get_gitattributes(self, tree: bytes | None = None) -> "GitAttributes": 

2688 """Read gitattributes for the repository. 

2689 

2690 Args: 

2691 tree: Tree SHA to read .gitattributes from (defaults to HEAD) 

2692 

2693 Returns: 

2694 GitAttributes object that can be used to match paths 

2695 """ 

2696 from .attrs import ( 

2697 GitAttributes, 

2698 compile_gitattributes_patterns, 

2699 parse_git_attributes, 

2700 ) 

2701 

2702 patterns = [] 

2703 

2704 # Read system gitattributes (TODO: implement this) 

2705 # Read global gitattributes (TODO: implement this) 

2706 

2707 # Read repository .gitattributes from index/tree 

2708 if tree is None: 

2709 try: 

2710 # Try to get from HEAD 

2711 head = self[b"HEAD"] 

2712 # Peel tags to get to the underlying commit 

2713 while isinstance(head, Tag): 

2714 _cls, obj = head.object 

2715 head = self.get_object(obj) 

2716 if not isinstance(head, Commit): 

2717 raise ValueError( 

2718 f"Expected HEAD to point to a Commit, got {type(head).__name__}. " 

2719 f"This usually means HEAD points to a {type(head).__name__} object " 

2720 f"instead of a Commit." 

2721 ) 

2722 tree = head.tree 

2723 except KeyError: 

2724 # No HEAD, no attributes from tree 

2725 pass 

2726 

2727 if tree is not None: 

2728 try: 

2729 tree_obj = self[tree] 

2730 assert isinstance(tree_obj, Tree) 

2731 if b".gitattributes" in tree_obj: 

2732 _, attrs_sha = tree_obj[b".gitattributes"] 

2733 attrs_blob = self[attrs_sha] 

2734 if isinstance(attrs_blob, Blob): 

2735 attrs_data = BytesIO(attrs_blob.data) 

2736 patterns.extend( 

2737 compile_gitattributes_patterns( 

2738 parse_git_attributes(attrs_data), b".gitattributes" 

2739 ) 

2740 ) 

2741 except (KeyError, NotTreeError): 

2742 pass 

2743 

2744 # Read .git/info/attributes 

2745 info_attrs_path = os.path.join(self.controldir(), "info", "attributes") 

2746 if os.path.exists(info_attrs_path): 

2747 with open(info_attrs_path, "rb") as f: 

2748 patterns.extend( 

2749 compile_gitattributes_patterns( 

2750 parse_git_attributes(f), info_attrs_path 

2751 ) 

2752 ) 

2753 

2754 # Read .gitattributes from working directory (if it exists). Like git, 

2755 # don't follow a symlink there. 

2756 working_attrs_path = os.path.join(self.path, ".gitattributes") 

2757 try: 

2758 working_attrs_file = open_nofollow_read(working_attrs_path) 

2759 except FileNotFoundError: 

2760 pass 

2761 except OSError as e: 

2762 if e.errno not in (errno.ELOOP, errno.EMLINK): 

2763 raise 

2764 logger.warning("Ignoring %s: it is a symbolic link", working_attrs_path) 

2765 else: 

2766 with working_attrs_file as f: 

2767 patterns.extend( 

2768 compile_gitattributes_patterns( 

2769 parse_git_attributes(f), working_attrs_path 

2770 ) 

2771 ) 

2772 

2773 return GitAttributes(patterns) 

2774 

2775 

2776class MemoryRepo(BaseRepo): 

2777 """Repo that stores refs, objects, and named files in memory. 

2778 

2779 MemoryRepos are always bare: they have no working tree and no index, since 

2780 those have a stronger dependency on the filesystem. 

2781 """ 

2782 

2783 filter_context: "FilterContext | None" 

2784 

2785 def __init__(self) -> None: 

2786 """Create a new repository in memory.""" 

2787 from .config import ConfigFile 

2788 from .object_format import DEFAULT_OBJECT_FORMAT 

2789 

2790 self._reflog: list[Any] = [] 

2791 refs_container = DictRefsContainer({}, logger=self._append_reflog) 

2792 BaseRepo.__init__(self, MemoryObjectStore(), refs_container) 

2793 self._named_files: dict[str, bytes] = {} 

2794 self.bare = True 

2795 self._config = ConfigFile() 

2796 self._description: bytes | None = None 

2797 self.filter_context = None 

2798 # MemoryRepo defaults to default object format 

2799 self.object_format = DEFAULT_OBJECT_FORMAT 

2800 

2801 def _append_reflog( 

2802 self, 

2803 ref: bytes, 

2804 old_sha: bytes | None, 

2805 new_sha: bytes | None, 

2806 committer: bytes | None, 

2807 timestamp: int | None, 

2808 timezone: int | None, 

2809 message: bytes | None, 

2810 ) -> None: 

2811 self._reflog.append( 

2812 (ref, old_sha, new_sha, committer, timestamp, timezone, message) 

2813 ) 

2814 

2815 def set_description(self, description: bytes) -> None: 

2816 """Set the description for this repository. 

2817 

2818 Args: 

2819 description: Text to set as description 

2820 """ 

2821 self._description = description 

2822 

2823 def get_description(self) -> bytes | None: 

2824 """Get the description of this repository. 

2825 

2826 Returns: 

2827 Repository description as bytes 

2828 """ 

2829 return self._description 

2830 

2831 def _determine_file_mode(self) -> bool: 

2832 """Probe the file-system to determine whether permissions can be trusted. 

2833 

2834 Returns: True if permissions can be trusted, False otherwise. 

2835 """ 

2836 return sys.platform != "win32" 

2837 

2838 def _determine_symlinks(self) -> bool: 

2839 """Probe the file-system to determine whether permissions can be trusted. 

2840 

2841 Returns: True if permissions can be trusted, False otherwise. 

2842 """ 

2843 return sys.platform != "win32" 

2844 

2845 def _put_named_file(self, path: str, contents: bytes) -> None: 

2846 """Write a file to the control dir with the given name and contents. 

2847 

2848 Args: 

2849 path: The path to the file, relative to the control dir. 

2850 contents: A string to write to the file. 

2851 """ 

2852 self._named_files[path] = contents 

2853 

2854 def _del_named_file(self, path: str) -> None: 

2855 try: 

2856 del self._named_files[path] 

2857 except KeyError: 

2858 pass 

2859 

2860 def get_named_file( 

2861 self, 

2862 path: str | bytes, 

2863 basedir: str | None = None, 

2864 ) -> BytesIO | None: 

2865 """Get a file from the control dir with a specific name. 

2866 

2867 Although the filename should be interpreted as a filename relative to 

2868 the control dir in a disk-baked Repo, the object returned need not be 

2869 pointing to a file in that location. 

2870 

2871 Args: 

2872 path: The path to the file, relative to the control dir. 

2873 basedir: Optional base directory for the path 

2874 Returns: An open file object, or None if the file does not exist. 

2875 """ 

2876 path_str = path.decode() if isinstance(path, bytes) else path 

2877 contents = self._named_files.get(path_str, None) 

2878 if contents is None: 

2879 return None 

2880 return BytesIO(contents) 

2881 

2882 def open_index(self, config: "Config | None" = None) -> "Index": 

2883 """Fail to open index for this repo, since it is bare. 

2884 

2885 Args: 

2886 config: Unused; kept for signature compatibility with ``BaseRepo``. 

2887 

2888 Raises: 

2889 NoIndexPresent: Raised when no index is present 

2890 """ 

2891 raise NoIndexPresent 

2892 

2893 def _init_config(self, config: "ConfigFile") -> None: 

2894 """Initialize repository configuration for MemoryRepo.""" 

2895 self._config = config 

2896 

2897 def get_config(self) -> "ConfigFile": 

2898 """Retrieve the config object. 

2899 

2900 Returns: `ConfigFile` object. 

2901 """ 

2902 return self._config 

2903 

2904 def get_rebase_state_manager(self) -> "RebaseStateManager": 

2905 """Get the appropriate rebase state manager for this repository. 

2906 

2907 Returns: MemoryRebaseStateManager instance 

2908 """ 

2909 from .rebase import MemoryRebaseStateManager 

2910 

2911 return MemoryRebaseStateManager(self) 

2912 

2913 def get_blob_normalizer( 

2914 self, config: "Config | None" = None 

2915 ) -> "FilterBlobNormalizer": 

2916 """Return a BlobNormalizer object for checkin/checkout operations. 

2917 

2918 Args: 

2919 config: Configuration to consult for filter setup. If None, 

2920 falls back to ``self.get_config_stack()``. 

2921 """ 

2922 from .filters import FilterBlobNormalizer, FilterContext, FilterRegistry 

2923 

2924 if config is None: 

2925 config = self.get_config_stack() 

2926 git_attributes = self.get_gitattributes() 

2927 

2928 if self.filter_context is None: 

2929 filter_registry = FilterRegistry(config, self) 

2930 self.filter_context = FilterContext(filter_registry) 

2931 else: 

2932 self.filter_context.refresh_config(config) 

2933 

2934 return FilterBlobNormalizer( 

2935 config, git_attributes, filter_context=self.filter_context 

2936 ) 

2937 

2938 def get_gitattributes(self, tree: bytes | None = None) -> "GitAttributes": 

2939 """Read gitattributes for the repository.""" 

2940 from .attrs import GitAttributes 

2941 

2942 # Memory repos don't have working trees or gitattributes files 

2943 # Return empty GitAttributes 

2944 return GitAttributes([]) 

2945 

2946 def close(self) -> None: 

2947 """Close any resources opened by this repository.""" 

2948 # Clean up filter context if it was created 

2949 if self.filter_context is not None: 

2950 self.filter_context.close() 

2951 self.filter_context = None 

2952 # Close object store to release pack files 

2953 self.object_store.close() 

2954 

2955 def do_commit( 

2956 self, 

2957 message: bytes | None = None, 

2958 committer: bytes | None = None, 

2959 author: bytes | None = None, 

2960 commit_timestamp: float | None = None, 

2961 commit_timezone: int | None = None, 

2962 author_timestamp: float | None = None, 

2963 author_timezone: int | None = None, 

2964 tree: ObjectID | None = None, 

2965 encoding: bytes | None = None, 

2966 ref: Ref | None = HEADREF, 

2967 merge_heads: list[ObjectID] | None = None, 

2968 no_verify: bool = False, 

2969 sign: bool = False, 

2970 config: "Config | None" = None, 

2971 ) -> bytes: 

2972 """Create a new commit. 

2973 

2974 This is a simplified implementation for in-memory repositories that 

2975 doesn't support worktree operations or hooks. 

2976 

2977 Args: 

2978 message: Commit message 

2979 committer: Committer fullname 

2980 author: Author fullname 

2981 commit_timestamp: Commit timestamp (defaults to now) 

2982 commit_timezone: Commit timestamp timezone (defaults to GMT) 

2983 author_timestamp: Author timestamp (defaults to commit timestamp) 

2984 author_timezone: Author timestamp timezone (defaults to commit timezone) 

2985 tree: SHA1 of the tree root to use 

2986 encoding: Encoding 

2987 ref: Optional ref to commit to (defaults to current branch). 

2988 If None, creates a dangling commit without updating any ref. 

2989 merge_heads: Merge heads 

2990 no_verify: Skip pre-commit and commit-msg hooks (ignored for MemoryRepo) 

2991 sign: GPG Sign the commit (ignored for MemoryRepo) 

2992 config: Configuration to consult for committer/author identity. If 

2993 None, falls back to ``self.get_config_stack()``. 

2994 

2995 Returns: 

2996 New commit SHA1 

2997 """ 

2998 import time 

2999 

3000 from .objects import Commit 

3001 

3002 if tree is None: 

3003 raise ValueError("tree must be specified for MemoryRepo") 

3004 

3005 c = Commit() 

3006 if len(tree) != self.object_format.hex_length: 

3007 raise ValueError( 

3008 f"tree must be a {self.object_format.hex_length}-character hex sha string" 

3009 ) 

3010 c.tree = tree 

3011 

3012 if config is None: 

3013 config = self.get_config_stack() 

3014 if merge_heads is None: 

3015 merge_heads = [] 

3016 if committer is None: 

3017 committer = get_user_identity(config, kind="COMMITTER") 

3018 check_user_identity(committer) 

3019 c.committer = committer 

3020 if commit_timestamp is None: 

3021 commit_timestamp = time.time() 

3022 c.commit_time = int(commit_timestamp) 

3023 if commit_timezone is None: 

3024 commit_timezone = 0 

3025 c.commit_timezone = commit_timezone 

3026 if author is None: 

3027 author = get_user_identity(config, kind="AUTHOR") 

3028 c.author = author 

3029 check_user_identity(author) 

3030 if author_timestamp is None: 

3031 author_timestamp = commit_timestamp 

3032 c.author_time = int(author_timestamp) 

3033 if author_timezone is None: 

3034 author_timezone = commit_timezone 

3035 c.author_timezone = author_timezone 

3036 if encoding is None: 

3037 try: 

3038 encoding = config.get(("i18n",), "commitEncoding") 

3039 except KeyError: 

3040 pass 

3041 if encoding is not None: 

3042 c.encoding = encoding 

3043 

3044 # Handle message (for MemoryRepo, we don't support callable messages) 

3045 if callable(message): 

3046 message = message(self, c) 

3047 if message is None: 

3048 raise ValueError("Message callback returned None") 

3049 

3050 if message is None: 

3051 raise ValueError("No commit message specified") 

3052 

3053 c.message = message 

3054 

3055 if ref is None: 

3056 # Create a dangling commit 

3057 c.parents = merge_heads 

3058 self.object_store.add_object(c) 

3059 else: 

3060 try: 

3061 old_head = self.refs[ref] 

3062 c.parents = [old_head, *merge_heads] 

3063 self.object_store.add_object(c) 

3064 ok = self.refs.set_if_equals( 

3065 ref, 

3066 old_head, 

3067 c.id, 

3068 message=b"commit: " + message, 

3069 committer=committer, 

3070 timestamp=int(commit_timestamp), 

3071 timezone=commit_timezone, 

3072 ) 

3073 except KeyError: 

3074 c.parents = merge_heads 

3075 self.object_store.add_object(c) 

3076 ok = self.refs.add_if_new( 

3077 ref, 

3078 c.id, 

3079 message=b"commit: " + message, 

3080 committer=committer, 

3081 timestamp=int(commit_timestamp), 

3082 timezone=commit_timezone, 

3083 ) 

3084 if not ok: 

3085 from .errors import CommitError 

3086 

3087 raise CommitError(f"{ref!r} changed during commit") 

3088 

3089 return c.id 

3090 

3091 @classmethod 

3092 def init_bare( 

3093 cls, 

3094 objects: Iterable[ShaFile], 

3095 refs: Mapping[Ref, ObjectID], 

3096 format: int | None = None, 

3097 object_format: str | None = None, 

3098 ) -> "MemoryRepo": 

3099 """Create a new bare repository in memory. 

3100 

3101 Args: 

3102 objects: Objects for the new repository, 

3103 as iterable 

3104 refs: Refs as dictionary, mapping names 

3105 to object SHA1s 

3106 format: Repository format version (defaults to 0) 

3107 object_format: Object format to use ("sha1" or "sha256", defaults to "sha1") 

3108 """ 

3109 ret = cls() 

3110 for obj in objects: 

3111 ret.object_store.add_object(obj) 

3112 for refname, sha in refs.items(): 

3113 ret.refs.add_if_new(refname, sha) 

3114 ret._init_files(bare=True, format=format, object_format=object_format) 

3115 return ret