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

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

1469 statements  

1# index.py -- File parser/writer for the git index file 

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

3# 

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

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

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

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

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

9# 

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

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

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

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

14# limitations under the License. 

15# 

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

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

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

19# License, Version 2.0. 

20# 

21 

22"""Parser for the git index file format.""" 

23 

24__all__ = [ 

25 "DEFAULT_VERSION", 

26 "EOIE_EXTENSION", 

27 "EXTENDED_FLAG_INTEND_TO_ADD", 

28 "EXTENDED_FLAG_SKIP_WORKTREE", 

29 "FLAG_EXTENDED", 

30 "FLAG_NAMEMASK", 

31 "FLAG_STAGEMASK", 

32 "FLAG_STAGESHIFT", 

33 "FLAG_VALID", 

34 "HFS_IGNORABLE_CHARS", 

35 "IEOT_EXTENSION", 

36 "INVALID_DOTNAMES", 

37 "RESERVED_WINDOWS_DEVICE_NAMES", 

38 "REUC_EXTENSION", 

39 "SDIR_EXTENSION", 

40 "TREE_EXTENSION", 

41 "UNTR_EXTENSION", 

42 "ConflictedIndexEntry", 

43 "Index", 

44 "IndexChecksumReader", 

45 "IndexChecksumWriter", 

46 "IndexEntry", 

47 "IndexExtension", 

48 "InvalidPathError", 

49 "ResolveUndoExtension", 

50 "SerializedIndexEntry", 

51 "SparseDirExtension", 

52 "Stage", 

53 "TreeDict", 

54 "TreeExtension", 

55 "UnmergedEntries", 

56 "UnsupportedIndexFormat", 

57 "UntrackedExtension", 

58 "apply_stat_refresh", 

59 "blob_from_path_and_mode", 

60 "blob_from_path_and_stat", 

61 "build_file_from_blob", 

62 "build_index_from_tree", 

63 "changes_from_tree", 

64 "cleanup_mode", 

65 "commit_index", 

66 "commit_tree", 

67 "detect_case_only_renames", 

68 "get_path_element_normalizer", 

69 "get_path_element_validator", 

70 "get_symlink_fn", 

71 "get_unstaged_changes", 

72 "index_entry_from_directory", 

73 "index_entry_from_path", 

74 "index_entry_from_stat", 

75 "index_entry_from_tree_entry", 

76 "iter_fresh_entries", 

77 "iter_fresh_objects", 

78 "locked_index", 

79 "make_path_normalizer", 

80 "os_sep_bytes", 

81 "pathjoin", 

82 "pathsplit", 

83 "read_cache_entry", 

84 "read_cache_time", 

85 "read_index", 

86 "read_index_dict", 

87 "read_index_dict_with_version", 

88 "read_index_header", 

89 "read_submodule_head", 

90 "refresh_index", 

91 "symlink", 

92 "update_working_tree", 

93 "validate_path", 

94 "validate_path_element_default", 

95 "validate_path_element_hfs", 

96 "validate_path_element_ntfs", 

97 "verify_leading_dirs", 

98 "verify_tree_path", 

99 "write_cache_entry", 

100 "write_cache_time", 

101 "write_index", 

102 "write_index_dict", 

103 "write_index_extension", 

104] 

105 

106import errno 

107import logging 

108import os 

109import shutil 

110import stat 

111import struct 

112import sys 

113import types 

114from collections.abc import ( 

115 Callable, 

116 Generator, 

117 Iterable, 

118 Iterator, 

119 Mapping, 

120 Sequence, 

121 Set, 

122) 

123from dataclasses import dataclass 

124from enum import Enum 

125from typing import ( 

126 TYPE_CHECKING, 

127 Any, 

128 Protocol, 

129) 

130 

131if TYPE_CHECKING: 

132 from .config import Config 

133 from .diff_tree import TreeChange 

134 from .file import _GitFile 

135 from .filters import FilterBlobNormalizer 

136 from .object_store import BaseObjectStore 

137 from .repo import Repo 

138 

139from . import _deprecated_aliases 

140from .errors import ChecksumMismatch 

141from .file import GitFile, SharedPerm 

142from .object_format import DEFAULT_OBJECT_FORMAT, ObjectFormat 

143from .object_store import iter_tree_contents 

144from .objects import ( 

145 S_IFGITLINK, 

146 S_ISGITLINK, 

147 Blob, 

148 ObjectID, 

149 RawObjectID, 

150 Tree, 

151 TreeEntry, 

152 hex_to_sha, 

153 sha_to_hex, 

154) 

155from .pack import ObjectContainer 

156 

157logger = logging.getLogger(__name__) 

158 

159 

160__getattr__ = _deprecated_aliases( 

161 __name__, 

162 { 

163 "SHA1Reader": "dulwich.pack.SHA1Reader", 

164 "SHA1Writer": "dulwich.pack.SHA1Writer", 

165 }, 

166) 

167 

168 

169# Type alias for recursive tree structure used in commit_tree 

170TreeDict = dict[bytes, "TreeDict | tuple[int, ObjectID]"] 

171 

172# 2-bit stage (during merge) 

173FLAG_STAGEMASK = 0x3000 

174FLAG_STAGESHIFT = 12 

175FLAG_NAMEMASK = 0x0FFF 

176 

177# assume-valid 

178FLAG_VALID = 0x8000 

179 

180# extended flag (must be zero in version 2) 

181FLAG_EXTENDED = 0x4000 

182 

183# used by sparse checkout 

184EXTENDED_FLAG_SKIP_WORKTREE = 0x4000 

185 

186# used by "git add -N" 

187EXTENDED_FLAG_INTEND_TO_ADD = 0x2000 

188 

189DEFAULT_VERSION = 2 

190 

191# Index extension signatures 

192TREE_EXTENSION = b"TREE" 

193REUC_EXTENSION = b"REUC" 

194UNTR_EXTENSION = b"UNTR" 

195EOIE_EXTENSION = b"EOIE" 

196IEOT_EXTENSION = b"IEOT" 

197SDIR_EXTENSION = b"sdir" # Sparse directory extension 

198 

199 

200def _encode_varint(value: int) -> bytes: 

201 """Encode an integer using variable-width encoding. 

202 

203 Same format as used for OFS_DELTA pack entries and index v4 path compression. 

204 Uses 7 bits per byte, with the high bit indicating continuation. 

205 

206 Args: 

207 value: Integer to encode 

208 Returns: 

209 Encoded bytes 

210 """ 

211 if value == 0: 

212 return b"\x00" 

213 

214 result = [] 

215 while value > 0: 

216 byte = value & 0x7F # Take lower 7 bits 

217 value >>= 7 

218 if value > 0: 

219 byte |= 0x80 # Set continuation bit 

220 result.append(byte) 

221 

222 return bytes(result) 

223 

224 

225def _decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]: 

226 """Decode a variable-width encoded integer. 

227 

228 Args: 

229 data: Bytes to decode from 

230 offset: Starting offset in data 

231 Returns: 

232 tuple of (decoded_value, new_offset) 

233 """ 

234 value = 0 

235 shift = 0 

236 pos = offset 

237 

238 while pos < len(data): 

239 byte = data[pos] 

240 pos += 1 

241 value |= (byte & 0x7F) << shift 

242 shift += 7 

243 if not (byte & 0x80): # No continuation bit 

244 break 

245 

246 return value, pos 

247 

248 

249def _compress_path(path: bytes, previous_path: bytes) -> bytes: 

250 """Compress a path relative to the previous path for index version 4. 

251 

252 Args: 

253 path: Path to compress 

254 previous_path: Previous path for comparison 

255 Returns: 

256 Compressed path data (varint prefix_len + suffix) 

257 """ 

258 # Find the common prefix length 

259 common_len = 0 

260 min_len = min(len(path), len(previous_path)) 

261 

262 for i in range(min_len): 

263 if path[i] == previous_path[i]: 

264 common_len += 1 

265 else: 

266 break 

267 

268 # The number of bytes to remove from the end of previous_path 

269 # to get the common prefix 

270 remove_len = len(previous_path) - common_len 

271 

272 # The suffix to append 

273 suffix = path[common_len:] 

274 

275 # Encode: varint(remove_len) + suffix + NUL 

276 return _encode_varint(remove_len) + suffix + b"\x00" 

277 

278 

279def _decompress_path( 

280 data: bytes, offset: int, previous_path: bytes 

281) -> tuple[bytes, int]: 

282 """Decompress a path from index version 4 compressed format. 

283 

284 Args: 

285 data: Raw data containing compressed path 

286 offset: Starting offset in data 

287 previous_path: Previous path for decompression 

288 Returns: 

289 tuple of (decompressed_path, new_offset) 

290 """ 

291 # Decode the number of bytes to remove from previous path 

292 remove_len, new_offset = _decode_varint(data, offset) 

293 

294 # Find the NUL terminator for the suffix 

295 suffix_start = new_offset 

296 suffix_end = suffix_start 

297 while suffix_end < len(data) and data[suffix_end] != 0: 

298 suffix_end += 1 

299 

300 if suffix_end >= len(data): 

301 raise ValueError("Unterminated path suffix in compressed entry") 

302 

303 suffix = data[suffix_start:suffix_end] 

304 new_offset = suffix_end + 1 # Skip the NUL terminator 

305 

306 # Reconstruct the path 

307 if remove_len > len(previous_path): 

308 raise ValueError( 

309 f"Invalid path compression: trying to remove {remove_len} bytes from {len(previous_path)}-byte path" 

310 ) 

311 

312 prefix = previous_path[:-remove_len] if remove_len > 0 else previous_path 

313 path = prefix + suffix 

314 

315 return path, new_offset 

316 

317 

318class _IndexWritable(Protocol): 

319 """The subset of a binary file that the index serializer writes to.""" 

320 

321 def write(self, data: bytes, /) -> int: 

322 """Write data, returning the number of bytes written.""" 

323 

324 def tell(self) -> int: 

325 """Return the current stream position.""" 

326 

327 def close(self) -> None: 

328 """Close the stream.""" 

329 

330 

331class _IndexReadable(Protocol): 

332 """The subset of a binary file that the index parser reads from.""" 

333 

334 def read(self, size: int = -1, /) -> bytes: 

335 """Read up to size bytes.""" 

336 

337 def tell(self) -> int: 

338 """Return the current stream position.""" 

339 

340 def seek(self, offset: int, whence: int = 0, /) -> int: 

341 """Change the stream position.""" 

342 

343 

344def _decompress_path_from_stream( 

345 f: _IndexReadable, previous_path: bytes 

346) -> tuple[bytes, int]: 

347 """Decompress a path from index version 4 compressed format, reading from stream. 

348 

349 Args: 

350 f: File-like object to read from 

351 previous_path: Previous path for decompression 

352 Returns: 

353 tuple of (decompressed_path, bytes_consumed) 

354 """ 

355 # Decode the varint for remove_len by reading byte by byte 

356 remove_len = 0 

357 shift = 0 

358 bytes_consumed = 0 

359 

360 while True: 

361 byte_data = f.read(1) 

362 if not byte_data: 

363 raise ValueError("Unexpected end of file while reading varint") 

364 byte = byte_data[0] 

365 bytes_consumed += 1 

366 remove_len |= (byte & 0x7F) << shift 

367 shift += 7 

368 if not (byte & 0x80): # No continuation bit 

369 break 

370 

371 # Read the suffix until NUL terminator 

372 suffix = b"" 

373 while True: 

374 byte_data = f.read(1) 

375 if not byte_data: 

376 raise ValueError("Unexpected end of file while reading path suffix") 

377 byte = byte_data[0] 

378 bytes_consumed += 1 

379 if byte == 0: # NUL terminator 

380 break 

381 suffix += bytes([byte]) 

382 

383 # Reconstruct the path 

384 if remove_len > len(previous_path): 

385 raise ValueError( 

386 f"Invalid path compression: trying to remove {remove_len} bytes from {len(previous_path)}-byte path" 

387 ) 

388 

389 prefix = previous_path[:-remove_len] if remove_len > 0 else previous_path 

390 path = prefix + suffix 

391 

392 return path, bytes_consumed 

393 

394 

395class Stage(Enum): 

396 """Represents the stage of an index entry during merge conflicts.""" 

397 

398 NORMAL = 0 

399 MERGE_CONFLICT_ANCESTOR = 1 

400 MERGE_CONFLICT_THIS = 2 

401 MERGE_CONFLICT_OTHER = 3 

402 

403 

404@dataclass 

405class SerializedIndexEntry: 

406 """Represents a serialized index entry as stored in the index file. 

407 

408 This dataclass holds the raw data for an index entry before it's 

409 parsed into the more user-friendly IndexEntry format. 

410 """ 

411 

412 name: bytes 

413 ctime: int | float | tuple[int, int] 

414 mtime: int | float | tuple[int, int] 

415 dev: int 

416 ino: int 

417 mode: int 

418 uid: int 

419 gid: int 

420 size: int 

421 sha: ObjectID 

422 flags: int 

423 extended_flags: int 

424 

425 def stage(self) -> Stage: 

426 """Extract the stage from the flags field. 

427 

428 Returns: 

429 Stage enum value indicating merge conflict state 

430 """ 

431 return Stage((self.flags & FLAG_STAGEMASK) >> FLAG_STAGESHIFT) 

432 

433 def is_sparse_dir(self) -> bool: 

434 """Check if this entry represents a sparse directory. 

435 

436 A sparse directory entry is a collapsed representation of an entire 

437 directory tree in a sparse index. It has: 

438 - Directory mode (0o040000) 

439 - SKIP_WORKTREE flag set 

440 - Path ending with '/' 

441 - SHA pointing to a tree object 

442 

443 Returns: 

444 True if entry is a sparse directory entry 

445 """ 

446 return ( 

447 stat.S_ISDIR(self.mode) 

448 and bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

449 and self.name.endswith(b"/") 

450 ) 

451 

452 

453@dataclass 

454class IndexExtension: 

455 """Base class for index extensions.""" 

456 

457 signature: bytes 

458 data: bytes 

459 

460 @classmethod 

461 def from_raw(cls, signature: bytes, data: bytes) -> "IndexExtension": 

462 """Create an extension from raw data. 

463 

464 Args: 

465 signature: 4-byte extension signature 

466 data: Extension data 

467 Returns: 

468 Parsed extension object 

469 """ 

470 if signature == TREE_EXTENSION: 

471 return TreeExtension.from_bytes(data) 

472 elif signature == REUC_EXTENSION: 

473 return ResolveUndoExtension.from_bytes(data) 

474 elif signature == UNTR_EXTENSION: 

475 return UntrackedExtension.from_bytes(data) 

476 elif signature == SDIR_EXTENSION: 

477 return SparseDirExtension.from_bytes(data) 

478 else: 

479 # Unknown extension - just store raw data 

480 return cls(signature, data) 

481 

482 def to_bytes(self) -> bytes: 

483 """Serialize extension to bytes.""" 

484 return self.data 

485 

486 

487class TreeExtension(IndexExtension): 

488 """Tree cache extension.""" 

489 

490 def __init__(self, entries: list[tuple[bytes, bytes, int]]) -> None: 

491 """Initialize TreeExtension. 

492 

493 Args: 

494 entries: List of tree cache entries (path, sha, flags) 

495 """ 

496 self.entries = entries 

497 super().__init__(TREE_EXTENSION, b"") 

498 

499 @classmethod 

500 def from_bytes(cls, data: bytes) -> "TreeExtension": 

501 """Parse TreeExtension from bytes. 

502 

503 Args: 

504 data: Raw bytes to parse 

505 

506 Returns: 

507 TreeExtension instance 

508 """ 

509 # TODO: Implement tree cache parsing 

510 return cls([]) 

511 

512 def to_bytes(self) -> bytes: 

513 """Serialize TreeExtension to bytes. 

514 

515 Returns: 

516 Serialized extension data 

517 """ 

518 # TODO: Implement tree cache serialization 

519 return b"" 

520 

521 

522class ResolveUndoExtension(IndexExtension): 

523 """Resolve undo extension for recording merge conflicts.""" 

524 

525 def __init__(self, entries: list[tuple[bytes, list[tuple[int, bytes]]]]) -> None: 

526 """Initialize ResolveUndoExtension. 

527 

528 Args: 

529 entries: List of (path, stages) where stages is a list of (stage, sha) tuples 

530 """ 

531 self.entries = entries 

532 super().__init__(REUC_EXTENSION, b"") 

533 

534 @classmethod 

535 def from_bytes(cls, data: bytes) -> "ResolveUndoExtension": 

536 """Parse ResolveUndoExtension from bytes. 

537 

538 Args: 

539 data: Raw bytes to parse 

540 

541 Returns: 

542 ResolveUndoExtension instance 

543 """ 

544 # TODO: Implement resolve undo parsing 

545 return cls([]) 

546 

547 def to_bytes(self) -> bytes: 

548 """Serialize ResolveUndoExtension to bytes. 

549 

550 Returns: 

551 Serialized extension data 

552 """ 

553 # TODO: Implement resolve undo serialization 

554 return b"" 

555 

556 

557class UntrackedExtension(IndexExtension): 

558 """Untracked cache extension.""" 

559 

560 def __init__(self, data: bytes) -> None: 

561 """Initialize UntrackedExtension. 

562 

563 Args: 

564 data: Raw untracked cache data 

565 """ 

566 super().__init__(UNTR_EXTENSION, data) 

567 

568 @classmethod 

569 def from_bytes(cls, data: bytes) -> "UntrackedExtension": 

570 """Parse UntrackedExtension from bytes. 

571 

572 Args: 

573 data: Raw bytes to parse 

574 

575 Returns: 

576 UntrackedExtension instance 

577 """ 

578 return cls(data) 

579 

580 

581class SparseDirExtension(IndexExtension): 

582 """Sparse directory extension. 

583 

584 This extension indicates that the index contains sparse directory entries. 

585 Tools that don't understand sparse index should avoid interacting with 

586 the index when this extension is present. 

587 

588 The extension data is empty - its presence is the signal. 

589 """ 

590 

591 def __init__(self) -> None: 

592 """Initialize SparseDirExtension.""" 

593 super().__init__(SDIR_EXTENSION, b"") 

594 

595 @classmethod 

596 def from_bytes(cls, data: bytes) -> "SparseDirExtension": 

597 """Parse SparseDirExtension from bytes. 

598 

599 Args: 

600 data: Raw bytes to parse (should be empty) 

601 

602 Returns: 

603 SparseDirExtension instance 

604 """ 

605 return cls() 

606 

607 def to_bytes(self) -> bytes: 

608 """Serialize SparseDirExtension to bytes. 

609 

610 Returns: 

611 Empty bytes (extension presence is the signal) 

612 """ 

613 return b"" 

614 

615 

616@dataclass 

617class IndexEntry: 

618 """Represents an entry in the Git index. 

619 

620 This is a higher-level representation of an index entry that includes 

621 parsed data and convenience methods. 

622 """ 

623 

624 ctime: int | float | tuple[int, int] 

625 mtime: int | float | tuple[int, int] 

626 dev: int 

627 ino: int 

628 mode: int 

629 uid: int 

630 gid: int 

631 size: int 

632 sha: ObjectID 

633 flags: int = 0 

634 extended_flags: int = 0 

635 

636 @classmethod 

637 def from_serialized(cls, serialized: SerializedIndexEntry) -> "IndexEntry": 

638 """Create an IndexEntry from a SerializedIndexEntry. 

639 

640 Args: 

641 serialized: SerializedIndexEntry to convert 

642 

643 Returns: 

644 New IndexEntry instance 

645 """ 

646 return cls( 

647 ctime=serialized.ctime, 

648 mtime=serialized.mtime, 

649 dev=serialized.dev, 

650 ino=serialized.ino, 

651 mode=serialized.mode, 

652 uid=serialized.uid, 

653 gid=serialized.gid, 

654 size=serialized.size, 

655 sha=serialized.sha, 

656 flags=serialized.flags, 

657 extended_flags=serialized.extended_flags, 

658 ) 

659 

660 def serialize(self, name: bytes, stage: Stage) -> SerializedIndexEntry: 

661 """Serialize this entry with a given name and stage. 

662 

663 Args: 

664 name: Path name for the entry 

665 stage: Merge conflict stage 

666 

667 Returns: 

668 SerializedIndexEntry ready for writing to disk 

669 """ 

670 # Clear out any existing stage bits, then set them from the Stage. 

671 new_flags = self.flags & ~FLAG_STAGEMASK 

672 new_flags |= stage.value << FLAG_STAGESHIFT 

673 return SerializedIndexEntry( 

674 name=name, 

675 ctime=self.ctime, 

676 mtime=self.mtime, 

677 dev=self.dev, 

678 ino=self.ino, 

679 mode=self.mode, 

680 uid=self.uid, 

681 gid=self.gid, 

682 size=self.size, 

683 sha=self.sha, 

684 flags=new_flags, 

685 extended_flags=self.extended_flags, 

686 ) 

687 

688 def stage(self) -> Stage: 

689 """Get the merge conflict stage of this entry. 

690 

691 Returns: 

692 Stage enum value 

693 """ 

694 return Stage((self.flags & FLAG_STAGEMASK) >> FLAG_STAGESHIFT) 

695 

696 @property 

697 def skip_worktree(self) -> bool: 

698 """Return True if the skip-worktree bit is set in extended_flags.""" 

699 return bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

700 

701 def set_skip_worktree(self, skip: bool = True) -> None: 

702 """Helper method to set or clear the skip-worktree bit in extended_flags. 

703 

704 Also sets FLAG_EXTENDED in self.flags if needed. 

705 """ 

706 if skip: 

707 # Turn on the skip-worktree bit 

708 self.extended_flags |= EXTENDED_FLAG_SKIP_WORKTREE 

709 # Also ensure the main 'extended' bit is set in flags 

710 self.flags |= FLAG_EXTENDED 

711 else: 

712 # Turn off the skip-worktree bit 

713 self.extended_flags &= ~EXTENDED_FLAG_SKIP_WORKTREE 

714 # Optionally unset the main extended bit if no extended flags remain 

715 if self.extended_flags == 0: 

716 self.flags &= ~FLAG_EXTENDED 

717 

718 def is_sparse_dir(self, name: bytes) -> bool: 

719 """Check if this entry represents a sparse directory. 

720 

721 A sparse directory entry is a collapsed representation of an entire 

722 directory tree in a sparse index. It has: 

723 - Directory mode (0o040000) 

724 - SKIP_WORKTREE flag set 

725 - Path ending with '/' 

726 - SHA pointing to a tree object 

727 

728 Args: 

729 name: The path name for this entry (IndexEntry doesn't store name) 

730 

731 Returns: 

732 True if entry is a sparse directory entry 

733 """ 

734 return ( 

735 stat.S_ISDIR(self.mode) 

736 and bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

737 and name.endswith(b"/") 

738 ) 

739 

740 

741class ConflictedIndexEntry: 

742 """Index entry that represents a conflict.""" 

743 

744 ancestor: IndexEntry | None 

745 this: IndexEntry | None 

746 other: IndexEntry | None 

747 

748 def __init__( 

749 self, 

750 ancestor: IndexEntry | None = None, 

751 this: IndexEntry | None = None, 

752 other: IndexEntry | None = None, 

753 ) -> None: 

754 """Initialize ConflictedIndexEntry. 

755 

756 Args: 

757 ancestor: The common ancestor entry 

758 this: The current branch entry 

759 other: The other branch entry 

760 """ 

761 self.ancestor = ancestor 

762 self.this = this 

763 self.other = other 

764 

765 

766class UnmergedEntries(Exception): 

767 """Unmerged entries exist in the index.""" 

768 

769 

770def pathsplit(path: bytes) -> tuple[bytes, bytes]: 

771 """Split a /-delimited path into a directory part and a basename. 

772 

773 Args: 

774 path: The path to split. 

775 

776 Returns: 

777 Tuple with directory name and basename 

778 """ 

779 try: 

780 (dirname, basename) = path.rsplit(b"/", 1) 

781 except ValueError: 

782 return (b"", path) 

783 else: 

784 return (dirname, basename) 

785 

786 

787def pathjoin(*args: bytes) -> bytes: 

788 """Join a /-delimited path.""" 

789 return b"/".join([p for p in args if p]) 

790 

791 

792def read_cache_time(f: _IndexReadable) -> tuple[int, int]: 

793 """Read a cache time. 

794 

795 Args: 

796 f: File-like object to read from 

797 Returns: 

798 Tuple with seconds and nanoseconds 

799 """ 

800 return struct.unpack(">LL", f.read(8)) 

801 

802 

803def write_cache_time(f: _IndexWritable, t: int | float | tuple[int, int]) -> None: 

804 """Write a cache time. 

805 

806 Args: 

807 f: File-like object to write to 

808 t: Time to write (as int, float or tuple with secs and nsecs) 

809 """ 

810 if isinstance(t, int): 

811 t = (t, 0) 

812 elif isinstance(t, float): 

813 (secs, nsecs) = divmod(t, 1.0) 

814 t = (int(secs), int(nsecs * 1000000000)) 

815 elif not isinstance(t, tuple): 

816 raise TypeError(t) 

817 f.write(struct.pack(">LL", *t)) 

818 

819 

820class IndexChecksumWriter: 

821 """Wrap a file, tracking the checksum of everything written to it. 

822 

823 The index trailer uses the repository's object format, so unlike 

824 :class:`dulwich.pack.SHA1Writer` this is not tied to SHA-1. 

825 """ 

826 

827 def __init__( 

828 self, f: _IndexWritable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

829 ) -> None: 

830 """Initialize the writer. 

831 

832 Args: 

833 f: File-like object to wrap 

834 object_format: Object format determining the checksum algorithm 

835 """ 

836 self.f = f 

837 self.object_format = object_format 

838 self._hash = object_format.new_hash() 

839 

840 def write(self, data: bytes) -> int: 

841 """Write data and update the running checksum.""" 

842 self._hash.update(data) 

843 return self.f.write(data) 

844 

845 def tell(self) -> int: 

846 """Return the position of the underlying file.""" 

847 return self.f.tell() 

848 

849 def write_checksum(self) -> bytes: 

850 """Write the trailing checksum and return it.""" 

851 digest = self._hash.digest() 

852 self.f.write(digest) 

853 return digest 

854 

855 def close(self) -> None: 

856 """Write the trailing checksum and close the underlying file.""" 

857 self.write_checksum() 

858 self.f.close() 

859 

860 

861class IndexChecksumReader: 

862 """Wrap a file, tracking the checksum of everything read from it.""" 

863 

864 def __init__( 

865 self, f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

866 ) -> None: 

867 """Initialize the reader. 

868 

869 Args: 

870 f: File-like object to wrap 

871 object_format: Object format determining the checksum algorithm 

872 """ 

873 self.f = f 

874 self.object_format = object_format 

875 self._hash = object_format.new_hash() 

876 

877 def read(self, size: int = -1) -> bytes: 

878 """Read data and update the running checksum.""" 

879 data = self.f.read(size) 

880 self._hash.update(data) 

881 return data 

882 

883 def tell(self) -> int: 

884 """Return the position of the underlying file.""" 

885 return self.f.tell() 

886 

887 def seek(self, offset: int, whence: int = 0) -> int: 

888 """Seek the underlying file. 

889 

890 Bytes skipped by seeking are not folded into the checksum, so this 

891 is only safe for the trailer probing done while reading extensions. 

892 """ 

893 return self.f.seek(offset, whence) 

894 

895 def check_checksum(self, allow_empty: bool = False) -> None: 

896 """Verify the trailing checksum. 

897 

898 Args: 

899 allow_empty: Accept an all-zero checksum, as written when the 

900 index.skipHash config option is set. 

901 

902 Raises: 

903 ChecksumMismatch: If the stored checksum does not match. 

904 """ 

905 length = self.object_format.oid_length 

906 stored = self.f.read(length) 

907 expected = self._hash.digest() 

908 if stored == expected: 

909 return 

910 if allow_empty and (len(stored) != length or stored == b"\x00" * length): 

911 return 

912 raise ChecksumMismatch( 

913 self._hash.hexdigest(), sha_to_hex(RawObjectID(stored)).decode("ascii") 

914 ) 

915 

916 

917def read_cache_entry( 

918 f: _IndexReadable, 

919 version: int, 

920 previous_path: bytes = b"", 

921 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

922) -> SerializedIndexEntry: 

923 """Read an entry from a cache file. 

924 

925 Args: 

926 f: File-like object to read from 

927 version: Index version 

928 previous_path: Previous entry's path (for version 4 compression) 

929 object_format: Object format determining the object ID width 

930 """ 

931 beginoffset = f.tell() 

932 ctime = read_cache_time(f) 

933 mtime = read_cache_time(f) 

934 oid_length = object_format.oid_length 

935 ( 

936 dev, 

937 ino, 

938 mode, 

939 uid, 

940 gid, 

941 size, 

942 sha, 

943 flags, 

944 ) = struct.unpack(f">LLLLLL{oid_length}sH", f.read(oid_length + 4 * 6 + 2)) 

945 if flags & FLAG_EXTENDED: 

946 if version < 3: 

947 raise AssertionError("extended flag set in index with version < 3") 

948 (extended_flags,) = struct.unpack(">H", f.read(2)) 

949 else: 

950 extended_flags = 0 

951 

952 if version >= 4: 

953 # Version 4: paths are always compressed (name_len should be 0) 

954 name, _consumed = _decompress_path_from_stream(f, previous_path) 

955 else: 

956 # Versions < 4: regular name reading 

957 name = f.read(flags & FLAG_NAMEMASK) 

958 

959 # Padding: 

960 if version < 4: 

961 real_size = (f.tell() - beginoffset + 8) & ~7 

962 f.read((beginoffset + real_size) - f.tell()) 

963 

964 return SerializedIndexEntry( 

965 name, 

966 ctime, 

967 mtime, 

968 dev, 

969 ino, 

970 mode, 

971 uid, 

972 gid, 

973 size, 

974 sha_to_hex(sha), 

975 flags & ~FLAG_NAMEMASK, 

976 extended_flags, 

977 ) 

978 

979 

980def write_cache_entry( 

981 f: _IndexWritable, 

982 entry: SerializedIndexEntry, 

983 version: int, 

984 previous_path: bytes = b"", 

985 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

986) -> None: 

987 """Write an index entry to a file. 

988 

989 Args: 

990 f: File object 

991 entry: IndexEntry to write 

992 version: Index format version 

993 previous_path: Previous entry's path (for version 4 compression) 

994 object_format: Object format determining the object ID width 

995 """ 

996 if len(entry.sha) != object_format.hex_length: 

997 raise ValueError( 

998 f"Object ID {entry.sha!r} for {entry.name!r} is not valid for " 

999 f"a {object_format.name} index" 

1000 ) 

1001 

1002 beginoffset = f.tell() 

1003 write_cache_time(f, entry.ctime) 

1004 write_cache_time(f, entry.mtime) 

1005 

1006 compressed_path = b"" 

1007 if version >= 4: 

1008 # Version 4: use compression but set name_len to actual filename length 

1009 # This matches how C Git implements index v4 flags 

1010 compressed_path = _compress_path(entry.name, previous_path) 

1011 flags = len(entry.name) | (entry.flags & ~FLAG_NAMEMASK) 

1012 

1013 if entry.extended_flags: 

1014 flags |= FLAG_EXTENDED 

1015 if flags & FLAG_EXTENDED and version is not None and version < 3: 

1016 raise AssertionError("unable to use extended flags in version < 3") 

1017 

1018 f.write( 

1019 struct.pack( 

1020 f">LLLLLL{object_format.oid_length}sH", 

1021 entry.dev & 0xFFFFFFFF, 

1022 entry.ino & 0xFFFFFFFF, 

1023 entry.mode, 

1024 entry.uid, 

1025 entry.gid, 

1026 entry.size, 

1027 hex_to_sha(entry.sha), 

1028 flags, 

1029 ) 

1030 ) 

1031 if flags & FLAG_EXTENDED: 

1032 f.write(struct.pack(b">H", entry.extended_flags)) 

1033 

1034 if version >= 4: 

1035 # Version 4: always write compressed path 

1036 f.write(compressed_path) 

1037 else: 

1038 # Versions < 4: write regular path and padding 

1039 f.write(entry.name) 

1040 real_size = (f.tell() - beginoffset + 8) & ~7 

1041 f.write(b"\0" * ((beginoffset + real_size) - f.tell())) 

1042 

1043 

1044class UnsupportedIndexFormat(Exception): 

1045 """An unsupported index format was encountered.""" 

1046 

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

1048 """Initialize UnsupportedIndexFormat exception. 

1049 

1050 Args: 

1051 version: The unsupported index format version 

1052 """ 

1053 self.index_format_version = version 

1054 

1055 

1056def read_index_header(f: _IndexReadable) -> tuple[int, int]: 

1057 """Read an index header from a file. 

1058 

1059 Returns: 

1060 tuple of (version, num_entries) 

1061 """ 

1062 header = f.read(4) 

1063 if header != b"DIRC": 

1064 raise AssertionError(f"Invalid index file header: {header!r}") 

1065 (version, num_entries) = struct.unpack(b">LL", f.read(4 * 2)) 

1066 if version not in (1, 2, 3, 4): 

1067 raise UnsupportedIndexFormat(version) 

1068 return version, num_entries 

1069 

1070 

1071def write_index_extension(f: _IndexWritable, extension: IndexExtension) -> None: 

1072 """Write an index extension. 

1073 

1074 Args: 

1075 f: File-like object to write to 

1076 extension: Extension to write 

1077 """ 

1078 data = extension.to_bytes() 

1079 f.write(extension.signature) 

1080 f.write(struct.pack(">I", len(data))) 

1081 f.write(data) 

1082 

1083 

1084def read_index( 

1085 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

1086) -> Iterator[SerializedIndexEntry]: 

1087 """Read an index file, yielding the individual entries.""" 

1088 version, num_entries = read_index_header(f) 

1089 previous_path = b"" 

1090 for i in range(num_entries): 

1091 entry = read_cache_entry(f, version, previous_path, object_format) 

1092 previous_path = entry.name 

1093 yield entry 

1094 

1095 

1096def read_index_dict_with_version( 

1097 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

1098) -> tuple[dict[bytes, IndexEntry | ConflictedIndexEntry], int, list[IndexExtension]]: 

1099 """Read an index file and return it as a dictionary along with the version. 

1100 

1101 Returns: 

1102 tuple of (entries_dict, version, extensions) 

1103 """ 

1104 version, num_entries = read_index_header(f) 

1105 

1106 ret: dict[bytes, IndexEntry | ConflictedIndexEntry] = {} 

1107 previous_path = b"" 

1108 for i in range(num_entries): 

1109 entry = read_cache_entry(f, version, previous_path, object_format) 

1110 previous_path = entry.name 

1111 stage = entry.stage() 

1112 if stage == Stage.NORMAL: 

1113 ret[entry.name] = IndexEntry.from_serialized(entry) 

1114 else: 

1115 existing = ret.setdefault(entry.name, ConflictedIndexEntry()) 

1116 if isinstance(existing, IndexEntry): 

1117 raise AssertionError(f"Non-conflicted entry for {entry.name!r} exists") 

1118 if stage == Stage.MERGE_CONFLICT_ANCESTOR: 

1119 existing.ancestor = IndexEntry.from_serialized(entry) 

1120 elif stage == Stage.MERGE_CONFLICT_THIS: 

1121 existing.this = IndexEntry.from_serialized(entry) 

1122 elif stage == Stage.MERGE_CONFLICT_OTHER: 

1123 existing.other = IndexEntry.from_serialized(entry) 

1124 

1125 # Read extensions 

1126 extensions = [] 

1127 while True: 

1128 # Check if we're at the end (trailing checksum before EOF) 

1129 current_pos = f.tell() 

1130 f.seek(0, 2) # EOF 

1131 eof_pos = f.tell() 

1132 f.seek(current_pos) 

1133 

1134 if current_pos >= eof_pos - object_format.oid_length: 

1135 break 

1136 

1137 # Try to read extension signature 

1138 signature = f.read(4) 

1139 if len(signature) < 4: 

1140 break 

1141 

1142 # Check if it's a valid extension signature (4 uppercase letters) 

1143 if not all(65 <= b <= 90 for b in signature): 

1144 # Not an extension, seek back 

1145 f.seek(-4, 1) 

1146 break 

1147 

1148 # Read extension size 

1149 size_data = f.read(4) 

1150 if len(size_data) < 4: 

1151 break 

1152 size = struct.unpack(">I", size_data)[0] 

1153 

1154 # Read extension data 

1155 data = f.read(size) 

1156 if len(data) < size: 

1157 break 

1158 

1159 extension = IndexExtension.from_raw(signature, data) 

1160 extensions.append(extension) 

1161 

1162 return ret, version, extensions 

1163 

1164 

1165def read_index_dict( 

1166 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

1167) -> dict[bytes, IndexEntry | ConflictedIndexEntry]: 

1168 """Read an index file and return it as a dictionary. 

1169 

1170 Dict Key is tuple of path and stage number, as 

1171 path alone is not unique 

1172 Args: 

1173 f: File object to read fromls. 

1174 object_format: Object format determining the object ID width 

1175 """ 

1176 ret: dict[bytes, IndexEntry | ConflictedIndexEntry] = {} 

1177 for entry in read_index(f, object_format): 

1178 stage = entry.stage() 

1179 if stage == Stage.NORMAL: 

1180 ret[entry.name] = IndexEntry.from_serialized(entry) 

1181 else: 

1182 existing = ret.setdefault(entry.name, ConflictedIndexEntry()) 

1183 if isinstance(existing, IndexEntry): 

1184 raise AssertionError(f"Non-conflicted entry for {entry.name!r} exists") 

1185 if stage == Stage.MERGE_CONFLICT_ANCESTOR: 

1186 existing.ancestor = IndexEntry.from_serialized(entry) 

1187 elif stage == Stage.MERGE_CONFLICT_THIS: 

1188 existing.this = IndexEntry.from_serialized(entry) 

1189 elif stage == Stage.MERGE_CONFLICT_OTHER: 

1190 existing.other = IndexEntry.from_serialized(entry) 

1191 return ret 

1192 

1193 

1194def write_index( 

1195 f: _IndexWritable, 

1196 entries: Sequence[SerializedIndexEntry], 

1197 version: int | None = None, 

1198 extensions: Sequence[IndexExtension] | None = None, 

1199 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1200) -> None: 

1201 """Write an index file. 

1202 

1203 Args: 

1204 f: File-like object to write to 

1205 version: Version number to write 

1206 entries: Iterable over the entries to write 

1207 extensions: Optional list of extensions to write 

1208 object_format: Object format determining the object ID width 

1209 """ 

1210 if version is None: 

1211 version = DEFAULT_VERSION 

1212 # STEP 1: check if any extended_flags are set 

1213 uses_extended_flags = any(e.extended_flags != 0 for e in entries) 

1214 if uses_extended_flags and version < 3: 

1215 # Force or bump the version to 3 

1216 version = 3 

1217 # The rest is unchanged, but you might insert a final check: 

1218 if version < 3: 

1219 # Double-check no extended flags appear 

1220 for e in entries: 

1221 if e.extended_flags != 0: 

1222 raise AssertionError("Attempt to use extended flags in index < v3") 

1223 # Proceed with the existing code to write the header and entries. 

1224 f.write(b"DIRC") 

1225 f.write(struct.pack(b">LL", version, len(entries))) 

1226 previous_path = b"" 

1227 for entry in entries: 

1228 write_cache_entry( 

1229 f, 

1230 entry, 

1231 version=version, 

1232 previous_path=previous_path, 

1233 object_format=object_format, 

1234 ) 

1235 previous_path = entry.name 

1236 

1237 # Write extensions 

1238 if extensions: 

1239 for extension in extensions: 

1240 write_index_extension(f, extension) 

1241 

1242 

1243def write_index_dict( 

1244 f: _IndexWritable, 

1245 entries: Mapping[bytes, IndexEntry | ConflictedIndexEntry], 

1246 version: int | None = None, 

1247 extensions: Sequence[IndexExtension] | None = None, 

1248 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1249) -> None: 

1250 """Write an index file based on the contents of a dictionary. 

1251 

1252 being careful to sort by path and then by stage. 

1253 """ 

1254 entries_list = [] 

1255 for key in sorted(entries): 

1256 value = entries[key] 

1257 if isinstance(value, ConflictedIndexEntry): 

1258 if value.ancestor is not None: 

1259 entries_list.append( 

1260 value.ancestor.serialize(key, Stage.MERGE_CONFLICT_ANCESTOR) 

1261 ) 

1262 if value.this is not None: 

1263 entries_list.append( 

1264 value.this.serialize(key, Stage.MERGE_CONFLICT_THIS) 

1265 ) 

1266 if value.other is not None: 

1267 entries_list.append( 

1268 value.other.serialize(key, Stage.MERGE_CONFLICT_OTHER) 

1269 ) 

1270 else: 

1271 entries_list.append(value.serialize(key, Stage.NORMAL)) 

1272 

1273 write_index( 

1274 f, 

1275 entries_list, 

1276 version=version, 

1277 extensions=extensions, 

1278 object_format=object_format, 

1279 ) 

1280 

1281 

1282def cleanup_mode(mode: int) -> int: 

1283 """Cleanup a mode value. 

1284 

1285 This will return a mode that can be stored in a tree object. 

1286 

1287 Args: 

1288 mode: Mode to clean up. 

1289 

1290 Returns: 

1291 mode 

1292 """ 

1293 if stat.S_ISLNK(mode): 

1294 return stat.S_IFLNK 

1295 elif stat.S_ISDIR(mode): 

1296 return stat.S_IFDIR 

1297 elif S_ISGITLINK(mode): 

1298 return S_IFGITLINK 

1299 ret = stat.S_IFREG | 0o644 

1300 if mode & 0o100: 

1301 ret |= 0o111 

1302 return ret 

1303 

1304 

1305class Index: 

1306 """A Git Index file.""" 

1307 

1308 _byname: dict[bytes, IndexEntry | ConflictedIndexEntry] 

1309 

1310 def __init__( 

1311 self, 

1312 filename: bytes | str | os.PathLike[str], 

1313 read: bool = True, 

1314 skip_hash: bool = False, 

1315 version: int | None = None, 

1316 *, 

1317 shared_perm: "SharedPerm | None" = None, 

1318 path_normalizer: Callable[[bytes], bytes] | None = None, 

1319 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1320 ) -> None: 

1321 """Create an index object associated with the given filename. 

1322 

1323 Args: 

1324 filename: Path to the index file 

1325 read: Whether to initialize the index from the given file, should it exist. 

1326 skip_hash: Whether to skip SHA1 hash when writing (for manyfiles feature) 

1327 version: Index format version to use (None = auto-detect from file or use default) 

1328 shared_perm: Optional shared repository permission setting 

1329 object_format: Object format of the repository this index belongs to. 

1330 Determines the width of stored object IDs and of the trailing 

1331 checksum. 

1332 path_normalizer: Optional function mapping a filesystem path to a 

1333 canonical form (e.g. case-folded, NFC-normalized). When provided, 

1334 lookups (``index[path]``, ``path in index``, ``del index[path]``) 

1335 transparently match paths that normalize to the same form as an 

1336 existing entry. 

1337 """ 

1338 self._filename = os.fspath(filename) 

1339 # TODO(jelmer): Store the version returned by read_index 

1340 self._version = version 

1341 self._skip_hash = skip_hash 

1342 self._shared_perm = shared_perm 

1343 self._object_format = object_format 

1344 self._extensions: list[IndexExtension] = [] 

1345 self._path_normalizer = path_normalizer 

1346 self._normalized: dict[bytes, bytes] | None = ( 

1347 {} if path_normalizer is not None else None 

1348 ) 

1349 self.clear() 

1350 if read: 

1351 self.read() 

1352 

1353 def canonical_path(self, name: bytes) -> bytes: 

1354 """Resolve ``name`` to the canonical key stored in the index. 

1355 

1356 If an entry already exists under ``name`` (or no normalizer is 

1357 configured), ``name`` is returned unchanged. Otherwise the 

1358 normalizer is applied and the key of any entry with the same 

1359 normalized form is returned. Falls back to ``name`` if none. 

1360 

1361 Normally callers do not need this because ``index[name]``, 

1362 ``name in index``, and ``del index[name]`` already apply 

1363 normalization transparently. Use this when the path is also 

1364 being used outside the index (for example to look up the same 

1365 entry in a commit tree), so that both sides agree on the key. 

1366 """ 

1367 if self._normalized is None or name in self._byname: 

1368 return name 

1369 assert self._path_normalizer is not None 

1370 return self._normalized.get(self._path_normalizer(name), name) 

1371 

1372 @property 

1373 def path(self) -> bytes | str: 

1374 """Get the path to the index file. 

1375 

1376 Returns: 

1377 Path to the index file 

1378 """ 

1379 return self._filename 

1380 

1381 @property 

1382 def object_format(self) -> ObjectFormat: 

1383 """Object format used for object IDs in this index.""" 

1384 return self._object_format 

1385 

1386 def __repr__(self) -> str: 

1387 """Return string representation of Index.""" 

1388 return f"{self.__class__.__name__}({self._filename!r})" 

1389 

1390 def write(self) -> None: 

1391 """Write current contents of index to disk.""" 

1392 f = GitFile(self._filename, "wb", shared_perm=self._shared_perm) 

1393 try: 

1394 # Filter out extensions with no meaningful data 

1395 meaningful_extensions = [] 

1396 for ext in self._extensions: 

1397 # Skip extensions that have empty data 

1398 ext_data = ext.to_bytes() 

1399 if ext_data: 

1400 meaningful_extensions.append(ext) 

1401 

1402 if self._skip_hash: 

1403 # When skipHash is enabled, write the index without computing 

1404 # the trailing checksum 

1405 write_index_dict( 

1406 f, 

1407 self._byname, 

1408 version=self._version, 

1409 extensions=meaningful_extensions, 

1410 object_format=self._object_format, 

1411 ) 

1412 # Write zero bytes instead of the checksum 

1413 f.write(b"\x00" * self._object_format.oid_length) 

1414 f.close() 

1415 else: 

1416 checksum_writer = IndexChecksumWriter(f, self._object_format) 

1417 write_index_dict( 

1418 checksum_writer, 

1419 self._byname, 

1420 version=self._version, 

1421 extensions=meaningful_extensions, 

1422 object_format=self._object_format, 

1423 ) 

1424 checksum_writer.close() 

1425 except: 

1426 f.close() 

1427 raise 

1428 

1429 def read(self) -> None: 

1430 """Read current contents of index from disk.""" 

1431 if not os.path.exists(self._filename): 

1432 return 

1433 f = GitFile(self._filename, "rb") 

1434 try: 

1435 checksum_reader = IndexChecksumReader(f, self._object_format) 

1436 entries, version, extensions = read_index_dict_with_version( 

1437 checksum_reader, self._object_format 

1438 ) 

1439 self._version = version 

1440 self._extensions = extensions 

1441 for name, value in entries.items(): 

1442 self.set_verbatim(name, value) 

1443 # Extensions have already been read by read_index_dict_with_version 

1444 checksum_reader.check_checksum(allow_empty=True) 

1445 finally: 

1446 f.close() 

1447 

1448 def __len__(self) -> int: 

1449 """Number of entries in this index file.""" 

1450 return len(self._byname) 

1451 

1452 def __getitem__(self, key: bytes) -> IndexEntry | ConflictedIndexEntry: 

1453 """Retrieve entry by relative path and stage. 

1454 

1455 Returns: Either a IndexEntry or a ConflictedIndexEntry 

1456 Raises KeyError: if the entry does not exist 

1457 """ 

1458 return self._byname[self.canonical_path(key)] 

1459 

1460 def __iter__(self) -> Iterator[bytes]: 

1461 """Iterate over the paths and stages in this index.""" 

1462 return iter(self._byname) 

1463 

1464 def __contains__(self, key: bytes) -> bool: 

1465 """Check if a path exists in the index.""" 

1466 return self.canonical_path(key) in self._byname 

1467 

1468 def get_sha1(self, path: bytes) -> ObjectID: 

1469 """Return the (git object) SHA1 for the object at a path.""" 

1470 value = self[path] 

1471 if isinstance(value, ConflictedIndexEntry): 

1472 raise UnmergedEntries 

1473 return value.sha 

1474 

1475 def get_mode(self, path: bytes) -> int: 

1476 """Return the POSIX file mode for the object at a path.""" 

1477 value = self[path] 

1478 if isinstance(value, ConflictedIndexEntry): 

1479 raise UnmergedEntries 

1480 return value.mode 

1481 

1482 def iterobjects(self) -> Iterable[tuple[bytes, ObjectID, int]]: 

1483 """Iterate over path, sha, mode tuples for use with commit_tree.""" 

1484 for path in self: 

1485 entry = self[path] 

1486 if isinstance(entry, ConflictedIndexEntry): 

1487 raise UnmergedEntries 

1488 yield path, entry.sha, cleanup_mode(entry.mode) 

1489 

1490 def has_conflicts(self) -> bool: 

1491 """Check if the index contains any conflicted entries. 

1492 

1493 Returns: 

1494 True if any entries are conflicted, False otherwise 

1495 """ 

1496 for value in self._byname.values(): 

1497 if isinstance(value, ConflictedIndexEntry): 

1498 return True 

1499 return False 

1500 

1501 def clear(self) -> None: 

1502 """Remove all contents from this index.""" 

1503 self._byname = {} 

1504 if self._normalized is not None: 

1505 self._normalized = {} 

1506 

1507 def __setitem__( 

1508 self, name: bytes, value: IndexEntry | ConflictedIndexEntry 

1509 ) -> None: 

1510 """Set an entry in the index.""" 

1511 assert isinstance(name, bytes) 

1512 name = self.canonical_path(name) 

1513 is_new = name not in self._byname 

1514 self._byname[name] = value 

1515 if is_new and self._normalized is not None: 

1516 assert self._path_normalizer is not None 

1517 self._normalized.setdefault(self._path_normalizer(name), name) 

1518 

1519 def set_verbatim( 

1520 self, name: bytes, value: IndexEntry | ConflictedIndexEntry 

1521 ) -> None: 

1522 """Set an entry without folding onto an existing normalized key. 

1523 

1524 Use this when populating the index from an authoritative source 

1525 that may legitimately hold two entries whose paths only differ by 

1526 case (or NFD/NFC form), such as an on-disk index or a tree from 

1527 the object store. ``__setitem__`` would collapse the second entry 

1528 onto the first via the path normalizer, losing content. 

1529 """ 

1530 assert isinstance(name, bytes) 

1531 self._byname[name] = value 

1532 if self._normalized is not None: 

1533 assert self._path_normalizer is not None 

1534 self._normalized.setdefault(self._path_normalizer(name), name) 

1535 

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

1537 """Delete an entry from the index.""" 

1538 name = self.canonical_path(name) 

1539 del self._byname[name] 

1540 if self._normalized is not None: 

1541 assert self._path_normalizer is not None 

1542 normalized_key = self._path_normalizer(name) 

1543 if self._normalized.get(normalized_key) == name: 

1544 del self._normalized[normalized_key] 

1545 

1546 def iteritems( 

1547 self, 

1548 ) -> Iterator[tuple[bytes, IndexEntry | ConflictedIndexEntry]]: 

1549 """Iterate over (path, entry) pairs in the index. 

1550 

1551 Returns: 

1552 Iterator of (path, entry) tuples 

1553 """ 

1554 return iter(self._byname.items()) 

1555 

1556 def items(self) -> Iterator[tuple[bytes, IndexEntry | ConflictedIndexEntry]]: 

1557 """Get an iterator over (path, entry) pairs. 

1558 

1559 Returns: 

1560 Iterator of (path, entry) tuples 

1561 """ 

1562 return iter(self._byname.items()) 

1563 

1564 def update(self, entries: dict[bytes, IndexEntry | ConflictedIndexEntry]) -> None: 

1565 """Update the index with multiple entries. 

1566 

1567 Args: 

1568 entries: Dictionary mapping paths to index entries 

1569 """ 

1570 for key, value in entries.items(): 

1571 self[key] = value 

1572 

1573 def paths(self) -> Generator[bytes, None, None]: 

1574 """Generate all paths in the index. 

1575 

1576 Yields: 

1577 Path names as bytes 

1578 """ 

1579 yield from self._byname.keys() 

1580 

1581 def changes_from_tree( 

1582 self, 

1583 object_store: ObjectContainer, 

1584 tree: ObjectID | None, 

1585 want_unchanged: bool = False, 

1586 ) -> Generator[ 

1587 tuple[ 

1588 tuple[bytes | None, bytes | None], 

1589 tuple[int | None, int | None], 

1590 tuple[bytes | None, bytes | None], 

1591 ], 

1592 None, 

1593 None, 

1594 ]: 

1595 """Find the differences between the contents of this index and a tree. 

1596 

1597 Args: 

1598 object_store: Object store to use for retrieving tree contents 

1599 tree: SHA1 of the root tree 

1600 want_unchanged: Whether unchanged files should be reported 

1601 Returns: Iterator over tuples with (oldpath, newpath), (oldmode, 

1602 newmode), (oldsha, newsha) 

1603 """ 

1604 

1605 def lookup_entry(path: bytes) -> tuple[bytes, int]: 

1606 entry = self[path] 

1607 if isinstance(entry, IndexEntry): 

1608 return entry.sha, cleanup_mode(entry.mode) 

1609 else: 

1610 # Handle ConflictedIndexEntry case 

1611 return b"", 0 

1612 

1613 yield from changes_from_tree( 

1614 self.paths(), 

1615 lookup_entry, 

1616 object_store, 

1617 tree, 

1618 want_unchanged=want_unchanged, 

1619 ) 

1620 

1621 def commit(self, object_store: ObjectContainer) -> ObjectID: 

1622 """Create a new tree from an index. 

1623 

1624 Args: 

1625 object_store: Object store to save the tree in 

1626 Returns: 

1627 Root tree SHA 

1628 """ 

1629 return commit_tree( 

1630 object_store, self.iterobjects(), object_format=self._object_format 

1631 ) 

1632 

1633 def is_sparse(self) -> bool: 

1634 """Check if this index contains sparse directory entries. 

1635 

1636 Returns: 

1637 True if any sparse directory extension is present 

1638 """ 

1639 return any(isinstance(ext, SparseDirExtension) for ext in self._extensions) 

1640 

1641 def ensure_full_index(self, object_store: "BaseObjectStore") -> None: 

1642 """Expand all sparse directory entries into full file entries. 

1643 

1644 This converts a sparse index into a full index by recursively 

1645 expanding any sparse directory entries into their constituent files. 

1646 

1647 Args: 

1648 object_store: Object store to read tree objects from 

1649 

1650 Raises: 

1651 KeyError: If a tree object referenced by a sparse dir entry doesn't exist 

1652 """ 

1653 if not self.is_sparse(): 

1654 return 

1655 

1656 # Find all sparse directory entries 

1657 sparse_dirs = [] 

1658 for path, entry in list(self._byname.items()): 

1659 if isinstance(entry, IndexEntry) and entry.is_sparse_dir(path): 

1660 sparse_dirs.append((path, entry)) 

1661 

1662 # Expand each sparse directory 

1663 for path, entry in sparse_dirs: 

1664 # Remove the sparse directory entry 

1665 del self[path] 

1666 

1667 # Get the tree object 

1668 tree = object_store[entry.sha] 

1669 if not isinstance(tree, Tree): 

1670 raise ValueError(f"Sparse directory {path!r} points to non-tree object") 

1671 

1672 # Recursively add all entries from the tree 

1673 self._expand_tree(path.rstrip(b"/"), tree, object_store, entry) 

1674 

1675 # Remove the sparse directory extension 

1676 self._extensions = [ 

1677 ext for ext in self._extensions if not isinstance(ext, SparseDirExtension) 

1678 ] 

1679 

1680 def _expand_tree( 

1681 self, 

1682 prefix: bytes, 

1683 tree: Tree, 

1684 object_store: "BaseObjectStore", 

1685 template_entry: IndexEntry, 

1686 ) -> None: 

1687 """Recursively expand a tree into index entries. 

1688 

1689 Args: 

1690 prefix: Path prefix for entries (without trailing slash) 

1691 tree: Tree object to expand 

1692 object_store: Object store to read nested trees from 

1693 template_entry: Template entry to copy metadata from 

1694 """ 

1695 for name, mode, sha in tree.items(): 

1696 if prefix: 

1697 full_path = prefix + b"/" + name 

1698 else: 

1699 full_path = name 

1700 

1701 if stat.S_ISDIR(mode): 

1702 # Recursively expand subdirectories 

1703 subtree = object_store[sha] 

1704 if not isinstance(subtree, Tree): 

1705 raise ValueError( 

1706 f"Directory entry {full_path!r} points to non-tree object" 

1707 ) 

1708 self._expand_tree(full_path, subtree, object_store, template_entry) 

1709 else: 

1710 # Create an index entry for this file 

1711 # Use the template entry for metadata but with the file's sha and mode 

1712 new_entry = IndexEntry( 

1713 ctime=template_entry.ctime, 

1714 mtime=template_entry.mtime, 

1715 dev=template_entry.dev, 

1716 ino=template_entry.ino, 

1717 mode=mode, 

1718 uid=template_entry.uid, 

1719 gid=template_entry.gid, 

1720 size=0, # Size is unknown from tree 

1721 sha=sha, 

1722 flags=0, 

1723 extended_flags=0, # Don't copy skip-worktree flag 

1724 ) 

1725 self[full_path] = new_entry 

1726 

1727 def convert_to_sparse( 

1728 self, 

1729 object_store: "BaseObjectStore", 

1730 tree_sha: ObjectID, 

1731 sparse_dirs: Set[bytes], 

1732 ) -> None: 

1733 """Convert full index entries to sparse directory entries. 

1734 

1735 This collapses directories that are entirely outside the sparse 

1736 checkout cone into single sparse directory entries. 

1737 

1738 Args: 

1739 object_store: Object store to read tree objects 

1740 tree_sha: SHA of the tree (usually HEAD) to base sparse dirs on 

1741 sparse_dirs: Set of directory paths (with trailing /) to collapse 

1742 

1743 Raises: 

1744 KeyError: If tree_sha or a subdirectory doesn't exist 

1745 """ 

1746 if not sparse_dirs: 

1747 return 

1748 

1749 # Get the base tree 

1750 tree = object_store[tree_sha] 

1751 if not isinstance(tree, Tree): 

1752 raise ValueError(f"tree_sha {tree_sha!r} is not a tree object") 

1753 

1754 # For each sparse directory, find its tree SHA and create sparse entry 

1755 for dir_path in sparse_dirs: 

1756 dir_path_stripped = dir_path.rstrip(b"/") 

1757 

1758 # Find the tree SHA for this directory 

1759 subtree_sha = self._find_subtree_sha(tree, dir_path_stripped, object_store) 

1760 if subtree_sha is None: 

1761 # Directory doesn't exist in tree, skip it 

1762 continue 

1763 

1764 # Remove all entries under this directory 

1765 entries_to_remove = [ 

1766 path 

1767 for path in self._byname 

1768 if path.startswith(dir_path) or path == dir_path_stripped 

1769 ] 

1770 for path in entries_to_remove: 

1771 del self[path] 

1772 

1773 # Create a sparse directory entry 

1774 # Use minimal metadata since it's not a real file 

1775 from dulwich.objects import ObjectID 

1776 

1777 sparse_entry = IndexEntry( 

1778 ctime=0, 

1779 mtime=0, 

1780 dev=0, 

1781 ino=0, 

1782 mode=stat.S_IFDIR, 

1783 uid=0, 

1784 gid=0, 

1785 size=0, 

1786 sha=ObjectID(subtree_sha), 

1787 flags=0, 

1788 extended_flags=EXTENDED_FLAG_SKIP_WORKTREE, 

1789 ) 

1790 self[dir_path] = sparse_entry 

1791 

1792 # Add sparse directory extension if not present 

1793 if not self.is_sparse(): 

1794 self._extensions.append(SparseDirExtension()) 

1795 

1796 def _find_subtree_sha( 

1797 self, 

1798 tree: Tree, 

1799 path: bytes, 

1800 object_store: "BaseObjectStore", 

1801 ) -> bytes | None: 

1802 """Find the SHA of a subtree at a given path. 

1803 

1804 Args: 

1805 tree: Root tree object to search in 

1806 path: Path to the subdirectory (no trailing slash) 

1807 object_store: Object store to read nested trees from 

1808 

1809 Returns: 

1810 SHA of the subtree, or None if path doesn't exist 

1811 """ 

1812 if not path: 

1813 return tree.get_id(self._object_format) 

1814 

1815 parts = path.split(b"/") 

1816 current_tree = tree 

1817 

1818 for part in parts: 

1819 # Look for this part in the current tree 

1820 try: 

1821 mode, sha = current_tree[part] 

1822 except KeyError: 

1823 return None 

1824 

1825 if not stat.S_ISDIR(mode): 

1826 # Path component is a file, not a directory 

1827 return None 

1828 

1829 # Load the next tree 

1830 obj = object_store[sha] 

1831 if not isinstance(obj, Tree): 

1832 return None 

1833 current_tree = obj 

1834 

1835 return current_tree.get_id(self._object_format) 

1836 

1837 

1838def commit_tree( 

1839 object_store: ObjectContainer, 

1840 blobs: Iterable[tuple[bytes, ObjectID, int]], 

1841 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1842) -> ObjectID: 

1843 """Commit a new tree. 

1844 

1845 Args: 

1846 object_store: Object store to add trees to 

1847 blobs: Iterable over blob path, sha, mode entries 

1848 object_format: Object format to name the created trees with 

1849 Returns: 

1850 ID of the created tree. 

1851 """ 

1852 trees: dict[bytes, TreeDict] = {b"": {}} 

1853 

1854 def add_tree(path: bytes) -> TreeDict: 

1855 if path in trees: 

1856 return trees[path] 

1857 dirname, basename = pathsplit(path) 

1858 t = add_tree(dirname) 

1859 assert isinstance(basename, bytes) 

1860 newtree: TreeDict = {} 

1861 t[basename] = newtree 

1862 trees[path] = newtree 

1863 return newtree 

1864 

1865 for path, sha, mode in blobs: 

1866 tree_path, basename = pathsplit(path) 

1867 tree = add_tree(tree_path) 

1868 tree[basename] = (mode, sha) 

1869 

1870 def build_tree(path: bytes) -> ObjectID: 

1871 tree = Tree() 

1872 for basename, entry in trees[path].items(): 

1873 if isinstance(entry, dict): 

1874 mode = stat.S_IFDIR 

1875 sha = build_tree(pathjoin(path, basename)) 

1876 else: 

1877 (mode, sha) = entry 

1878 tree.add(basename, mode, sha) 

1879 object_store.add_object(tree) 

1880 return ObjectID(tree.get_id(object_format)) 

1881 

1882 return build_tree(b"") 

1883 

1884 

1885def commit_index(object_store: ObjectContainer, index: Index) -> ObjectID: 

1886 """Create a new tree from an index. 

1887 

1888 Args: 

1889 object_store: Object store to save the tree in 

1890 index: Index file 

1891 Note: This function is deprecated, use index.commit() instead. 

1892 Returns: Root tree sha. 

1893 """ 

1894 return commit_tree( 

1895 object_store, index.iterobjects(), object_format=index.object_format 

1896 ) 

1897 

1898 

1899def changes_from_tree( 

1900 names: Iterable[bytes], 

1901 lookup_entry: Callable[[bytes], tuple[bytes, int]], 

1902 object_store: ObjectContainer, 

1903 tree: ObjectID | None, 

1904 want_unchanged: bool = False, 

1905) -> Iterable[ 

1906 tuple[ 

1907 tuple[bytes | None, bytes | None], 

1908 tuple[int | None, int | None], 

1909 tuple[bytes | None, bytes | None], 

1910 ] 

1911]: 

1912 """Find the differences between the contents of a tree and a working copy. 

1913 

1914 Args: 

1915 names: Iterable of names in the working copy 

1916 lookup_entry: Function to lookup an entry in the working copy 

1917 object_store: Object store to use for retrieving tree contents 

1918 tree: SHA1 of the root tree, or None for an empty tree 

1919 want_unchanged: Whether unchanged files should be reported 

1920 Returns: Iterator over tuples with (oldpath, newpath), (oldmode, newmode), 

1921 (oldsha, newsha) 

1922 """ 

1923 # TODO(jelmer): Support a include_trees option 

1924 other_names = set(names) 

1925 

1926 if tree is not None: 

1927 for name, mode, sha in iter_tree_contents(object_store, tree): 

1928 assert name is not None and mode is not None and sha is not None 

1929 try: 

1930 (other_sha, other_mode) = lookup_entry(name) 

1931 except KeyError: 

1932 # Was removed 

1933 yield ((name, None), (mode, None), (sha, None)) 

1934 else: 

1935 other_names.remove(name) 

1936 if want_unchanged or other_sha != sha or other_mode != mode: 

1937 yield ((name, name), (mode, other_mode), (sha, other_sha)) 

1938 

1939 # Mention added files 

1940 for name in other_names: 

1941 try: 

1942 (other_sha, other_mode) = lookup_entry(name) 

1943 except KeyError: 

1944 pass 

1945 else: 

1946 yield ((None, name), (None, other_mode), (None, other_sha)) 

1947 

1948 

1949def index_entry_from_stat( 

1950 stat_val: os.stat_result, 

1951 hex_sha: bytes, 

1952 mode: int | None = None, 

1953) -> IndexEntry: 

1954 """Create a new index entry from a stat value. 

1955 

1956 Args: 

1957 stat_val: POSIX stat_result instance 

1958 hex_sha: Hex sha of the object 

1959 mode: Optional file mode, will be derived from stat if not provided 

1960 """ 

1961 if mode is None: 

1962 mode = cleanup_mode(stat_val.st_mode) 

1963 

1964 from dulwich.objects import ObjectID 

1965 

1966 # Use nanosecond precision when available to avoid precision loss 

1967 # through float representation 

1968 ctime: int | float | tuple[int, int] 

1969 mtime: int | float | tuple[int, int] 

1970 st_ctime_ns = getattr(stat_val, "st_ctime_ns", None) 

1971 if st_ctime_ns is not None: 

1972 ctime = ( 

1973 st_ctime_ns // 1_000_000_000, 

1974 st_ctime_ns % 1_000_000_000, 

1975 ) 

1976 else: 

1977 ctime = stat_val.st_ctime 

1978 

1979 st_mtime_ns = getattr(stat_val, "st_mtime_ns", None) 

1980 if st_mtime_ns is not None: 

1981 mtime = ( 

1982 st_mtime_ns // 1_000_000_000, 

1983 st_mtime_ns % 1_000_000_000, 

1984 ) 

1985 else: 

1986 mtime = stat_val.st_mtime 

1987 

1988 return IndexEntry( 

1989 ctime=ctime, 

1990 mtime=mtime, 

1991 dev=stat_val.st_dev, 

1992 ino=stat_val.st_ino, 

1993 mode=mode, 

1994 uid=stat_val.st_uid, 

1995 gid=stat_val.st_gid, 

1996 size=stat_val.st_size, 

1997 sha=ObjectID(hex_sha), 

1998 flags=0, 

1999 extended_flags=0, 

2000 ) 

2001 

2002 

2003def index_entry_from_tree_entry( 

2004 mode: int, 

2005 hex_sha: bytes, 

2006 size: int = 0, 

2007) -> IndexEntry: 

2008 """Create an index entry from a tree entry, with zeroed stat fields. 

2009 

2010 Use this when populating an index directly from a tree without touching 

2011 the filesystem, matching ``git read-tree``. The stat-derived fields 

2012 (ctime/mtime/dev/ino/uid/gid) are zeroed; git treats such entries as 

2013 stat-unmerged and refreshes them on the next stat. 

2014 

2015 Args: 

2016 mode: File mode from the tree entry 

2017 hex_sha: Hex sha of the object 

2018 size: Size of the object (0 for gitlinks or when unknown) 

2019 """ 

2020 from dulwich.objects import ObjectID 

2021 

2022 return IndexEntry( 

2023 ctime=0, 

2024 mtime=0, 

2025 dev=0, 

2026 ino=0, 

2027 mode=mode, 

2028 uid=0, 

2029 gid=0, 

2030 size=size, 

2031 sha=ObjectID(hex_sha), 

2032 flags=0, 

2033 extended_flags=0, 

2034 ) 

2035 

2036 

2037if sys.platform == "win32": 

2038 # On Windows, creating symlinks either requires administrator privileges 

2039 # or developer mode. Raise a more helpful error when we're unable to 

2040 # create symlinks 

2041 

2042 # https://github.com/jelmer/dulwich/issues/1005 

2043 

2044 class WindowsSymlinkPermissionError(PermissionError): 

2045 """Windows-specific error for symlink creation failures. 

2046 

2047 This error is raised when symlink creation fails on Windows, 

2048 typically due to lack of developer mode or administrator privileges. 

2049 """ 

2050 

2051 def __init__(self, errno: int, msg: str, filename: str | None) -> None: 

2052 """Initialize WindowsSymlinkPermissionError.""" 

2053 super().__init__( 

2054 errno, 

2055 f"Unable to create symlink; do you have developer mode enabled? {msg}", 

2056 filename, 

2057 ) 

2058 

2059 def symlink( 

2060 src: str | bytes, 

2061 dst: str | bytes, 

2062 target_is_directory: bool = False, 

2063 *, 

2064 dir_fd: int | None = None, 

2065 ) -> None: 

2066 """Create a symbolic link on Windows with better error handling. 

2067 

2068 Args: 

2069 src: Source path for the symlink 

2070 dst: Destination path where symlink will be created 

2071 target_is_directory: Whether the target is a directory 

2072 dir_fd: Optional directory file descriptor 

2073 

2074 Raises: 

2075 WindowsSymlinkPermissionError: If symlink creation fails due to permissions 

2076 """ 

2077 try: 

2078 return os.symlink( 

2079 src, dst, target_is_directory=target_is_directory, dir_fd=dir_fd 

2080 ) 

2081 except PermissionError as e: 

2082 raise WindowsSymlinkPermissionError( 

2083 e.errno or 0, e.strerror or "", e.filename 

2084 ) from e 

2085else: 

2086 symlink = os.symlink 

2087 

2088 

2089def get_symlink_fn( 

2090 config: "Config", 

2091) -> Callable[[str | bytes | os.PathLike[str], str | bytes | os.PathLike[str]], None]: 

2092 """Get the function to use for creating symlinks in the working tree. 

2093 

2094 With ``core.symlinks`` set to false, symlinks are checked out as plain 

2095 files containing the link target, like git does. 

2096 

2097 Args: 

2098 config: Repository configuration 

2099 

2100 Returns: 

2101 Function taking the link target and the path to create 

2102 """ 

2103 if config.get_boolean(b"core", b"symlinks", True): 

2104 

2105 def create_symlink( 

2106 source: str | bytes | os.PathLike[str], 

2107 target: str | bytes | os.PathLike[str], 

2108 ) -> None: 

2109 symlink(source, target) # type: ignore[arg-type,unused-ignore] 

2110 

2111 return create_symlink 

2112 

2113 def write_link_as_file( 

2114 source: str | bytes | os.PathLike[str], 

2115 target: str | bytes | os.PathLike[str], 

2116 ) -> None: 

2117 mode = "w" + ("b" if isinstance(source, bytes) else "") 

2118 with open(target, mode) as f: 

2119 f.write(source) 

2120 

2121 return write_link_as_file 

2122 

2123 

2124def build_file_from_blob( 

2125 blob: Blob, 

2126 mode: int, 

2127 target_path: bytes, 

2128 *, 

2129 honor_filemode: bool = True, 

2130 tree_encoding: str = "utf-8", 

2131 symlink_fn: Callable[ 

2132 [str | bytes | os.PathLike[str], str | bytes | os.PathLike[str]], None 

2133 ] 

2134 | None = None, 

2135) -> os.stat_result: 

2136 """Build a file or symlink on disk based on a Git object. 

2137 

2138 Args: 

2139 blob: The git object 

2140 mode: File mode 

2141 target_path: Path to write to 

2142 honor_filemode: An optional flag to honor core.filemode setting in 

2143 config file, default is core.filemode=True, change executable bit 

2144 tree_encoding: Encoding to use for tree contents 

2145 symlink_fn: Function to use for creating symlinks 

2146 Returns: stat object for the file 

2147 """ 

2148 try: 

2149 oldstat = os.lstat(target_path) 

2150 except FileNotFoundError: 

2151 oldstat = None 

2152 contents = blob.as_raw_string() 

2153 if stat.S_ISLNK(mode): 

2154 if oldstat: 

2155 _remove_file_with_readonly_handling(target_path) 

2156 if sys.platform == "win32": 

2157 # os.readlink on Python3 on Windows requires a unicode string. 

2158 contents_str = contents.decode(tree_encoding) 

2159 target_path_str = target_path.decode(tree_encoding) 

2160 (symlink_fn or symlink)(contents_str, target_path_str) 

2161 else: 

2162 (symlink_fn or symlink)(contents, target_path) 

2163 else: 

2164 if oldstat is not None and stat.S_ISLNK(oldstat.st_mode): 

2165 # A symlink left at the target path must not be written through. 

2166 # open(..., "wb") would follow it and clobber whatever it points 

2167 # at, possibly outside the work tree. Replace it with a fresh 

2168 # regular file, like git does on checkout. 

2169 _remove_file_with_readonly_handling(target_path) 

2170 oldstat = None 

2171 if oldstat is not None and oldstat.st_size == len(contents): 

2172 with open(target_path, "rb") as f: 

2173 unchanged = f.read() == contents 

2174 if unchanged: 

2175 if not honor_filemode or bool(oldstat.st_mode & stat.S_IXUSR) == bool( 

2176 mode & stat.S_IXUSR 

2177 ): 

2178 return oldstat 

2179 os.chmod(target_path, cleanup_mode(mode)) 

2180 return os.lstat(target_path) 

2181 

2182 with open(target_path, "wb") as f: 

2183 # Write out file 

2184 f.write(contents) 

2185 

2186 if honor_filemode: 

2187 # Canonicalize to the permission bits git honors (0o644/0o755). 

2188 # The mode comes from a tree/index entry, which for an untrusted 

2189 # repository can carry setuid/setgid/sticky or world-writable bits; 

2190 # git reduces every regular-file mode to 0o644 or 0o755 on checkout. 

2191 os.chmod(target_path, cleanup_mode(mode)) 

2192 

2193 return os.lstat(target_path) 

2194 

2195 

2196INVALID_DOTNAMES = (b".git", b".", b"..", b"") 

2197 

2198 

2199def _normalize_path_element_default(element: bytes) -> bytes: 

2200 """Normalize path element for default case-insensitive comparison.""" 

2201 return element.lower() 

2202 

2203 

2204def _normalize_path_element_ntfs(element: bytes) -> bytes: 

2205 """Normalize path element for NTFS filesystem.""" 

2206 return element.rstrip(b". ").lower() 

2207 

2208 

2209def _normalize_path_element_hfs(element: bytes) -> bytes: 

2210 """Normalize path element for HFS+ filesystem.""" 

2211 import unicodedata 

2212 

2213 # Decode to Unicode (let UnicodeDecodeError bubble up) 

2214 element_str = element.decode("utf-8", errors="strict") 

2215 

2216 # Remove HFS+ ignorable characters 

2217 filtered = "".join(c for c in element_str if ord(c) not in HFS_IGNORABLE_CHARS) 

2218 # Normalize to NFD 

2219 normalized = unicodedata.normalize("NFD", filtered) 

2220 return normalized.lower().encode("utf-8", errors="strict") 

2221 

2222 

2223def get_path_element_normalizer(config: "Config") -> Callable[[bytes], bytes]: 

2224 """Get the appropriate path element normalization function based on config. 

2225 

2226 Args: 

2227 config: Repository configuration object 

2228 

2229 Returns: 

2230 Function that normalizes path elements for the configured filesystem 

2231 """ 

2232 import os 

2233 import sys 

2234 

2235 if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"): 

2236 return _normalize_path_element_ntfs 

2237 elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"): 

2238 return _normalize_path_element_hfs 

2239 else: 

2240 return _normalize_path_element_default 

2241 

2242 

2243def make_path_normalizer( 

2244 config: "Config", 

2245) -> Callable[[bytes], bytes] | None: 

2246 """Build a path normalizer honoring ``core.ignorecase`` and ``core.precomposeunicode``. 

2247 

2248 The returned callable maps a filesystem-form path to a canonical form 

2249 used to match equivalent paths (e.g. ``Foo.txt`` ↔ ``foo.txt`` when 

2250 ``core.ignorecase=true``, NFD ↔ NFC when ``core.precomposeunicode=true``). 

2251 Returns ``None`` when neither option is active so callers can skip the 

2252 comparison entirely. 

2253 """ 

2254 ignorecase = config.get_boolean(b"core", b"ignorecase", False) 

2255 precompose = config.get_boolean(b"core", b"precomposeunicode", False) 

2256 if not ignorecase and not precompose: 

2257 return None 

2258 

2259 def normalize(path: bytes) -> bytes: 

2260 if precompose: 

2261 import unicodedata 

2262 

2263 try: 

2264 path = unicodedata.normalize("NFC", path.decode("utf-8")).encode( 

2265 "utf-8" 

2266 ) 

2267 except UnicodeDecodeError: 

2268 pass 

2269 if ignorecase: 

2270 path = path.lower() 

2271 return path 

2272 

2273 return normalize 

2274 

2275 

2276class InvalidPathError(Exception): 

2277 """Raised when a tree entry's path is unsafe to write to the work tree.""" 

2278 

2279 def __init__(self, path: bytes) -> None: 

2280 """Initialize the exception with the offending path.""" 

2281 self.path = path 

2282 super().__init__(f"invalid path {path.decode('utf-8', 'replace')!r}") 

2283 

2284 

2285def validate_path_element_default(element: bytes) -> bool: 

2286 """Validate a path element using default rules. 

2287 

2288 Args: 

2289 element: Path element to validate 

2290 

2291 Returns: 

2292 True if path element is valid, False otherwise 

2293 """ 

2294 return _normalize_path_element_default(element) not in INVALID_DOTNAMES 

2295 

2296 

2297def _is_ntfs_dotgit(name: bytes) -> bool: 

2298 """Match NTFS-dangerous spellings of ``.git`` at the start of ``name``. 

2299 

2300 Matches ``.git`` or the 8.3 short name ``git~1`` followed only by 

2301 dots/spaces and then the end of the element, a separator, or a ``:`` 

2302 (an alternate-data-stream marker, as in ``.git::$INDEX_ALLOCATION``). 

2303 """ 

2304 if name[:1] == b".": 

2305 if name[1:4].lower() != b"git": 

2306 return False 

2307 i = 4 

2308 elif name[:1].lower() == b"g": # ``git~1`` 8.3 short name 

2309 if name[1:3].lower() != b"it" or name[3:5] != b"~1": 

2310 return False 

2311 i = 5 

2312 else: 

2313 return False 

2314 

2315 while i < len(name): 

2316 c = name[i : i + 1] 

2317 if c == b":": 

2318 return True 

2319 if c != b"." and c != b" ": 

2320 return False 

2321 i += 1 

2322 return True 

2323 

2324 

2325# Reserved Windows device names. Opening any of these on Windows 

2326# resolves to a device rather than a file, regardless of any 

2327# extension or trailing dots/spaces (``NUL``, ``NUL.txt``, 

2328# ``aux.foo.bar`` all hit the device). They are ordinary filenames 

2329# everywhere else, so this is only enforced when running on Windows. 

2330RESERVED_WINDOWS_DEVICE_NAMES = frozenset( 

2331 [b"con", b"prn", b"aux", b"nul"] 

2332 + [b"com%d" % i for i in range(1, 10)] 

2333 + [b"lpt%d" % i for i in range(1, 10)] 

2334) 

2335 

2336 

2337def _is_reserved_windows_device_name(normalized: bytes) -> bool: 

2338 """Match Windows reserved device names regardless of extension.""" 

2339 # The "stem" is the portion before the first ``.``; Windows 

2340 # also strips trailing spaces from that stem when resolving. 

2341 stem = normalized.split(b".", 1)[0].rstrip(b" ") 

2342 return stem in RESERVED_WINDOWS_DEVICE_NAMES 

2343 

2344 

2345def _has_dos_drive_prefix(name: bytes) -> bool: 

2346 """Return True if name begins with a DOS drive-letter prefix like 'C:'. 

2347 

2348 Any ASCII byte followed by a colon counts, matching the ASCII branch of 

2349 C git's win32_has_dos_drive_prefix: subst allows non-alphabetic drives. 

2350 """ 

2351 if len(name) < 2 or name[1:2] != b":": 

2352 return False 

2353 return name[0] < 0x80 

2354 

2355 

2356def validate_path_element_ntfs(element: bytes) -> bool: 

2357 """Validate a path element using NTFS filesystem rules. 

2358 

2359 Args: 

2360 element: Path element to validate 

2361 

2362 Returns: 

2363 True if path element is valid for NTFS, False otherwise 

2364 """ 

2365 # A backslash is a separator on Windows but an ordinary filename 

2366 # character on POSIX, so only reject it on Windows. 

2367 if os.name == "nt" and b"\\" in element: 

2368 return False 

2369 # Backslash also separates ``.git`` spellings, so check each segment. 

2370 for segment in element.split(b"\\"): 

2371 if _is_ntfs_dotgit(segment): 

2372 return False 

2373 normalized = _normalize_path_element_ntfs(element) 

2374 if normalized in INVALID_DOTNAMES: 

2375 return False 

2376 # Like the backslash check above, a reserved device name is only a 

2377 # hazard on Windows; C git confines this to is_valid_win32_path in 

2378 # compat/mingw.c, and accepts e.g. ``aux`` on POSIX (issue #2351). 

2379 if os.name == "nt" and _is_reserved_windows_device_name(normalized): 

2380 return False 

2381 return True 

2382 

2383 

2384# HFS+ ignorable Unicode codepoints (from Git's utf8.c) 

2385HFS_IGNORABLE_CHARS = { 

2386 0x200C, # ZERO WIDTH NON-JOINER 

2387 0x200D, # ZERO WIDTH JOINER 

2388 0x200E, # LEFT-TO-RIGHT MARK 

2389 0x200F, # RIGHT-TO-LEFT MARK 

2390 0x202A, # LEFT-TO-RIGHT EMBEDDING 

2391 0x202B, # RIGHT-TO-LEFT EMBEDDING 

2392 0x202C, # POP DIRECTIONAL FORMATTING 

2393 0x202D, # LEFT-TO-RIGHT OVERRIDE 

2394 0x202E, # RIGHT-TO-LEFT OVERRIDE 

2395 0x206A, # INHIBIT SYMMETRIC SWAPPING 

2396 0x206B, # ACTIVATE SYMMETRIC SWAPPING 

2397 0x206C, # INHIBIT ARABIC FORM SHAPING 

2398 0x206D, # ACTIVATE ARABIC FORM SHAPING 

2399 0x206E, # NATIONAL DIGIT SHAPES 

2400 0x206F, # NOMINAL DIGIT SHAPES 

2401 0xFEFF, # ZERO WIDTH NO-BREAK SPACE 

2402} 

2403 

2404 

2405def validate_path_element_hfs(element: bytes) -> bool: 

2406 """Validate path element for HFS+ filesystem. 

2407 

2408 Equivalent to Git's is_hfs_dotgit and related checks. 

2409 Uses NFD normalization and ignores HFS+ ignorable characters. 

2410 """ 

2411 try: 

2412 normalized = _normalize_path_element_hfs(element) 

2413 except UnicodeDecodeError: 

2414 # Malformed UTF-8 - be conservative and reject 

2415 return False 

2416 

2417 # Check against invalid names 

2418 if normalized in INVALID_DOTNAMES: 

2419 return False 

2420 

2421 # Also check for 8.3 short name 

2422 if normalized == b"git~1": 

2423 return False 

2424 

2425 return True 

2426 

2427 

2428def get_path_element_validator(config: "Config") -> Callable[[bytes], bool]: 

2429 """Get the path-element validator to use when checking out a tree. 

2430 

2431 ``core.protectNTFS`` defaults to true on every platform (matching Git's 

2432 ``PROTECT_NTFS_DEFAULT=1``) because a repository authored on POSIX can 

2433 still be cloned on Windows later; ``core.protectHFS`` defaults to true on 

2434 macOS. Both protections are independent and apply together, so on macOS 

2435 (where both default on) a path element must satisfy the NTFS and HFS+ 

2436 checks. With both disabled this falls back to the default validator, which 

2437 only refuses ``.git``, ``.`` and ``..``. 

2438 

2439 Args: 

2440 config: Repository configuration object 

2441 

2442 Returns: 

2443 Function that validates a single path element for the configured 

2444 filesystem protections. 

2445 """ 

2446 validators: list[Callable[[bytes], bool]] = [] 

2447 if config.get_boolean(b"core", b"protectNTFS", True): 

2448 validators.append(validate_path_element_ntfs) 

2449 if config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"): 

2450 validators.append(validate_path_element_hfs) 

2451 if not validators: 

2452 return validate_path_element_default 

2453 if len(validators) == 1: 

2454 return validators[0] 

2455 

2456 def validate_all(element: bytes) -> bool: 

2457 return all(validator(element) for validator in validators) 

2458 

2459 return validate_all 

2460 

2461 

2462def validate_path( 

2463 path: bytes, 

2464 element_validator: Callable[[bytes], bool] = validate_path_element_default, 

2465) -> bool: 

2466 """Default path validator that just checks for .git/.""" 

2467 # A leading drive-letter prefix lets os.path.join discard the work-tree 

2468 # root on Windows. Matches C git's verify_path, whose has_dos_drive_prefix 

2469 # is a no-op stub on non-Windows. 

2470 if os.name == "nt" and _has_dos_drive_prefix(path): 

2471 return False 

2472 parts = path.split(b"/") 

2473 for p in parts: 

2474 if not element_validator(p): 

2475 return False 

2476 else: 

2477 return True 

2478 

2479 

2480def verify_leading_dirs( 

2481 tree_path: bytes, 

2482 safe_prefix: list[bytes], 

2483 repo_path: bytes, 

2484) -> None: 

2485 """Reject writes whose leading path resolves through a symlink. 

2486 

2487 Callers that materialize many paths in sorted order can pass a shared 

2488 ``safe_prefix`` list to cache the deepest chain of directory components 

2489 already verified to be real directories (or absent); each call only 

2490 ``lstat``s the components that differ from that chain. Callers that only 

2491 verify a single path can pass an empty list. Mirrors git's 

2492 ``lstat_cache_matchlen`` (see CVE-2021-21300). 

2493 

2494 Args: 

2495 tree_path: Tree-form path (``/``-separated) about to be written. 

2496 safe_prefix: Mutable cache of directory components already verified 

2497 under ``repo_path``. Updated in place. 

2498 repo_path: Filesystem path to the work-tree root. 

2499 

2500 Raises: 

2501 InvalidPathError: If any leading component is a symlink. 

2502 """ 

2503 slash = tree_path.rfind(b"/") 

2504 if slash <= 0: 

2505 return 

2506 components = tree_path[:slash].split(b"/") 

2507 

2508 common = 0 

2509 while ( 

2510 common < len(safe_prefix) 

2511 and common < len(components) 

2512 and safe_prefix[common] == components[common] 

2513 ): 

2514 common += 1 

2515 del safe_prefix[common:] 

2516 

2517 current = repo_path 

2518 for part in components[:common]: 

2519 current = os.path.join(current, part) 

2520 for part in components[common:]: 

2521 current = os.path.join(current, part) 

2522 try: 

2523 st = os.lstat(current) 

2524 except FileNotFoundError: 

2525 # Anything below here doesn't exist yet; makedirs will create 

2526 # it under a verified-real-directory prefix. 

2527 break 

2528 if stat.S_ISLNK(st.st_mode): 

2529 raise InvalidPathError(tree_path) 

2530 safe_prefix.append(part) 

2531 

2532 

2533def verify_tree_path( 

2534 tree_path: bytes, 

2535 validate_path_element: Callable[[bytes], bool], 

2536 safe_prefix: list[bytes], 

2537 root_path: bytes, 

2538) -> None: 

2539 """Reject a tree path that is unsafe to write below ``root_path``. 

2540 

2541 Combines ``validate_path`` and ``verify_leading_dirs``. 

2542 

2543 Args: 

2544 tree_path: Tree-form path (``/``-separated) about to be written. 

2545 validate_path_element: Function to validate a single path element. 

2546 safe_prefix: Mutable cache shared across calls, see 

2547 ``verify_leading_dirs``. 

2548 root_path: Filesystem path to the work-tree root. 

2549 

2550 Raises: 

2551 InvalidPathError: If the path has a component rejected by 

2552 ``validate_path_element`` or a leading component is a symlink. 

2553 """ 

2554 if not validate_path(tree_path, validate_path_element): 

2555 raise InvalidPathError(tree_path) 

2556 verify_leading_dirs(tree_path, safe_prefix, root_path) 

2557 

2558 

2559def build_index_from_tree( 

2560 root_path: str | bytes, 

2561 index_path: str | bytes, 

2562 object_store: ObjectContainer, 

2563 tree_id: ObjectID, 

2564 honor_filemode: bool = True, 

2565 validate_path_element: Callable[[bytes], bool] = validate_path_element_default, 

2566 symlink_fn: Callable[ 

2567 [str | bytes | os.PathLike[str], str | bytes | os.PathLike[str]], None 

2568 ] 

2569 | None = None, 

2570 blob_normalizer: "FilterBlobNormalizer | None" = None, 

2571 tree_encoding: str = "utf-8", 

2572 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

2573) -> None: 

2574 """Generate and materialize index from a tree. 

2575 

2576 Args: 

2577 tree_id: Tree to materialize 

2578 root_path: Target dir for materialized index files 

2579 index_path: Target path for generated index 

2580 object_store: Non-empty object store holding tree contents 

2581 honor_filemode: An optional flag to honor core.filemode setting in 

2582 config file, default is core.filemode=True, change executable bit 

2583 validate_path_element: Function to validate path elements to check 

2584 out; default just refuses .git and .. directories. 

2585 symlink_fn: Function to use for creating symlinks 

2586 blob_normalizer: An optional BlobNormalizer to use for converting line 

2587 endings when writing blobs to the working directory. 

2588 tree_encoding: Encoding used for tree paths (default: utf-8) 

2589 object_format: Object format of the repository the index belongs to 

2590 

2591 Note: existing index is wiped and contents are not merged 

2592 in a working dir. Suitable only for fresh clones. 

2593 """ 

2594 index = Index(index_path, read=False, object_format=object_format) 

2595 if not isinstance(root_path, bytes): 

2596 root_path = os.fsencode(root_path) 

2597 

2598 # Cache of leading directory components already verified to be real 

2599 # directories, shared across the sorted iteration. See verify_leading_dirs. 

2600 safe_prefix: list[bytes] = [] 

2601 

2602 for entry in iter_tree_contents(object_store, tree_id): 

2603 assert ( 

2604 entry.path is not None and entry.mode is not None and entry.sha is not None 

2605 ) 

2606 # Validate as we go and abort on the first invalid path, leaving any 

2607 # files already written in place. This also refuses a leading path 

2608 # that resolves through a symlink materialized by an earlier entry. 

2609 verify_tree_path(entry.path, validate_path_element, safe_prefix, root_path) 

2610 full_path = _tree_to_fs_path(root_path, entry.path, tree_encoding) 

2611 

2612 if not os.path.exists(os.path.dirname(full_path)): 

2613 os.makedirs(os.path.dirname(full_path)) 

2614 

2615 # TODO(jelmer): Merge new index into working tree 

2616 if S_ISGITLINK(entry.mode): 

2617 if not os.path.isdir(full_path): 

2618 os.mkdir(full_path) 

2619 st = os.lstat(full_path) 

2620 # TODO(jelmer): record and return submodule paths 

2621 else: 

2622 obj = object_store[entry.sha] 

2623 assert isinstance(obj, Blob) 

2624 # Apply blob normalization for checkout if normalizer is provided 

2625 if blob_normalizer is not None: 

2626 obj = blob_normalizer.checkout_normalize(obj, entry.path) 

2627 st = build_file_from_blob( 

2628 obj, 

2629 entry.mode, 

2630 full_path, 

2631 honor_filemode=honor_filemode, 

2632 tree_encoding=tree_encoding, 

2633 symlink_fn=symlink_fn, 

2634 ) 

2635 

2636 # Add file to index 

2637 if not honor_filemode or S_ISGITLINK(entry.mode): 

2638 # we can not use tuple slicing to build a new tuple, 

2639 # because on windows that will convert the times to 

2640 # longs, which causes errors further along 

2641 st_tuple = ( 

2642 entry.mode, 

2643 st.st_ino, 

2644 st.st_dev, 

2645 st.st_nlink, 

2646 st.st_uid, 

2647 st.st_gid, 

2648 st.st_size, 

2649 st.st_atime, 

2650 st.st_mtime, 

2651 st.st_ctime, 

2652 ) 

2653 st = st.__class__(st_tuple) 

2654 # default to a stage 0 index entry (normal) 

2655 # when reading from the filesystem 

2656 index[entry.path] = index_entry_from_stat(st, entry.sha) 

2657 

2658 index.write() 

2659 

2660 

2661def blob_from_path_and_mode( 

2662 fs_path: bytes, mode: int, tree_encoding: str = "utf-8" 

2663) -> Blob: 

2664 """Create a blob from a path and a stat object. 

2665 

2666 Args: 

2667 fs_path: Full file system path to file 

2668 mode: File mode 

2669 tree_encoding: Encoding to use for tree contents 

2670 Returns: A `Blob` object 

2671 """ 

2672 assert isinstance(fs_path, bytes) 

2673 blob = Blob() 

2674 if stat.S_ISLNK(mode): 

2675 if sys.platform == "win32": 

2676 # os.readlink on Python3 on Windows requires a unicode string. 

2677 blob.data = os.readlink(os.fsdecode(fs_path)).encode(tree_encoding) 

2678 else: 

2679 blob.data = os.readlink(fs_path) 

2680 else: 

2681 with open(fs_path, "rb") as f: 

2682 blob.data = f.read() 

2683 return blob 

2684 

2685 

2686def blob_from_path_and_stat( 

2687 fs_path: bytes, st: os.stat_result, tree_encoding: str = "utf-8" 

2688) -> Blob: 

2689 """Create a blob from a path and a stat object. 

2690 

2691 Args: 

2692 fs_path: Full file system path to file 

2693 st: A stat object 

2694 tree_encoding: Encoding to use for tree contents 

2695 Returns: A `Blob` object 

2696 """ 

2697 return blob_from_path_and_mode(fs_path, st.st_mode, tree_encoding) 

2698 

2699 

2700def read_submodule_head(path: str | bytes) -> bytes | None: 

2701 """Read the head commit of a submodule. 

2702 

2703 Args: 

2704 path: path to the submodule 

2705 Returns: HEAD sha, None if not a valid head/repository 

2706 """ 

2707 from .errors import NotGitRepository 

2708 from .repo import Repo 

2709 

2710 # Repo currently expects a "str", so decode if necessary. 

2711 # TODO(jelmer): Perhaps move this into Repo() ? 

2712 if not isinstance(path, str): 

2713 path = os.fsdecode(path) 

2714 try: 

2715 repo = Repo(path) 

2716 except NotGitRepository: 

2717 return None 

2718 try: 

2719 return repo.head() 

2720 except KeyError: 

2721 return None 

2722 

2723 

2724def _has_directory_changed(tree_path: bytes, entry: IndexEntry) -> bool: 

2725 """Check if a directory has changed after getting an error. 

2726 

2727 When handling an error trying to create a blob from a path, call this 

2728 function. It will check if the path is a directory. If it's a directory 

2729 and a submodule, check the submodule head to see if it's has changed. If 

2730 not, consider the file as changed as Git tracked a file and not a 

2731 directory. 

2732 

2733 Return true if the given path should be considered as changed and False 

2734 otherwise or if the path is not a directory. 

2735 """ 

2736 # This is actually a directory 

2737 if os.path.exists(os.path.join(tree_path, b".git")): 

2738 # Submodule 

2739 head = read_submodule_head(tree_path) 

2740 if entry.sha != head: 

2741 return True 

2742 else: 

2743 # The file was changed to a directory, so consider it removed. 

2744 return True 

2745 

2746 return False 

2747 

2748 

2749os_sep_bytes = os.sep.encode("ascii") 

2750 

2751 

2752def _ensure_parent_dir_exists(full_path: bytes) -> None: 

2753 """Ensure parent directory exists, checking no parent is a file.""" 

2754 parent_dir = os.path.dirname(full_path) 

2755 if parent_dir and not os.path.exists(parent_dir): 

2756 # Walk up the directory tree to find the first existing parent 

2757 current = parent_dir 

2758 parents_to_check: list[bytes] = [] 

2759 

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

2761 parents_to_check.insert(0, current) 

2762 new_parent = os.path.dirname(current) 

2763 if new_parent == current: 

2764 # Reached the root or can't go up further 

2765 break 

2766 current = new_parent 

2767 

2768 # Check if the existing parent (if any) is a directory 

2769 if current and os.path.exists(current) and not os.path.isdir(current): 

2770 raise OSError( 

2771 f"Cannot create directory, parent path is a file: {current!r}" 

2772 ) 

2773 

2774 # Now check each parent we need to create isn't blocked by an existing file 

2775 for parent_path in parents_to_check: 

2776 if os.path.exists(parent_path) and not os.path.isdir(parent_path): 

2777 raise OSError( 

2778 f"Cannot create directory, parent path is a file: {parent_path!r}" 

2779 ) 

2780 

2781 os.makedirs(parent_dir) 

2782 

2783 

2784def _remove_file_with_readonly_handling(path: bytes) -> None: 

2785 """Remove a file, handling read-only files on Windows. 

2786 

2787 Args: 

2788 path: Path to the file to remove 

2789 """ 

2790 try: 

2791 os.unlink(path) 

2792 except PermissionError: 

2793 # On Windows, remove read-only attribute and retry 

2794 if sys.platform == "win32": 

2795 os.chmod(path, stat.S_IWRITE | stat.S_IREAD) 

2796 os.unlink(path) 

2797 else: 

2798 raise 

2799 

2800 

2801def _remove_empty_parents(path: bytes, stop_at: bytes) -> None: 

2802 """Remove empty parent directories up to stop_at.""" 

2803 parent = os.path.dirname(path) 

2804 while parent and parent != stop_at: 

2805 try: 

2806 os.rmdir(parent) 

2807 parent = os.path.dirname(parent) 

2808 except FileNotFoundError: 

2809 # Directory doesn't exist - stop trying 

2810 break 

2811 except OSError as e: 

2812 if e.errno in (errno.ENOTEMPTY, errno.EEXIST): 

2813 # Directory not empty - stop trying 

2814 break 

2815 raise 

2816 

2817 

2818def _check_symlink_matches( 

2819 full_path: bytes, repo_object_store: "BaseObjectStore", entry_sha: ObjectID 

2820) -> bool: 

2821 """Check if symlink target matches expected target. 

2822 

2823 Returns True if symlink matches, False if it doesn't match. 

2824 """ 

2825 try: 

2826 current_target = os.readlink(full_path) 

2827 blob_obj = repo_object_store[entry_sha] 

2828 expected_target = blob_obj.as_raw_string() 

2829 if isinstance(current_target, str): 

2830 current_target = current_target.encode() 

2831 return current_target == expected_target 

2832 except FileNotFoundError: 

2833 # Symlink doesn't exist 

2834 return False 

2835 except OSError as e: 

2836 if e.errno == errno.EINVAL: 

2837 # Not a symlink 

2838 return False 

2839 raise 

2840 

2841 

2842def _check_file_matches( 

2843 repo_object_store: "BaseObjectStore", 

2844 full_path: bytes, 

2845 entry_sha: ObjectID, 

2846 entry_mode: int, 

2847 current_stat: os.stat_result, 

2848 honor_filemode: bool, 

2849 blob_normalizer: "FilterBlobNormalizer | None" = None, 

2850 tree_path: bytes | None = None, 

2851) -> bool: 

2852 """Check if a file on disk matches the expected git object. 

2853 

2854 Returns True if file matches, False if it doesn't match. 

2855 """ 

2856 # Check mode first (if honor_filemode is True) 

2857 if honor_filemode: 

2858 current_mode = stat.S_IMODE(current_stat.st_mode) 

2859 expected_mode = stat.S_IMODE(entry_mode) 

2860 

2861 # For regular files, only check the user executable bit, not group/other permissions 

2862 # This matches Git's behavior where umask differences don't count as modifications 

2863 if stat.S_ISREG(current_stat.st_mode): 

2864 # Normalize regular file modes to ignore group/other write permissions 

2865 current_mode_normalized = ( 

2866 current_mode & 0o755 

2867 ) # Keep only user rwx and all read+execute 

2868 expected_mode_normalized = expected_mode & 0o755 

2869 

2870 # For Git compatibility, regular files should be either 644 or 755 

2871 if expected_mode_normalized not in (0o644, 0o755): 

2872 expected_mode_normalized = 0o644 # Default for regular files 

2873 if current_mode_normalized not in (0o644, 0o755): 

2874 # Determine if it should be executable based on user execute bit 

2875 if current_mode & 0o100: # User execute bit is set 

2876 current_mode_normalized = 0o755 

2877 else: 

2878 current_mode_normalized = 0o644 

2879 

2880 if current_mode_normalized != expected_mode_normalized: 

2881 return False 

2882 else: 

2883 # For non-regular files (symlinks, etc.), check mode exactly 

2884 if current_mode != expected_mode: 

2885 return False 

2886 

2887 # If mode matches (or we don't care), check content via size first 

2888 blob_obj = repo_object_store[entry_sha] 

2889 if current_stat.st_size != blob_obj.raw_length(): 

2890 return False 

2891 

2892 # Size matches, check actual content 

2893 try: 

2894 with open(full_path, "rb") as f: 

2895 current_content = f.read() 

2896 expected_content = blob_obj.as_raw_string() 

2897 if blob_normalizer and tree_path is not None: 

2898 assert isinstance(blob_obj, Blob) 

2899 normalized_blob = blob_normalizer.checkout_normalize( 

2900 blob_obj, tree_path 

2901 ) 

2902 expected_content = normalized_blob.as_raw_string() 

2903 return current_content == expected_content 

2904 except (FileNotFoundError, PermissionError, IsADirectoryError): 

2905 return False 

2906 

2907 

2908def _transition_to_submodule( 

2909 repo: "Repo", 

2910 path: bytes, 

2911 full_path: bytes, 

2912 current_stat: os.stat_result | None, 

2913 entry: IndexEntry | TreeEntry, 

2914 index: Index, 

2915) -> None: 

2916 """Transition any type to submodule.""" 

2917 from .submodule import ensure_submodule_placeholder 

2918 

2919 if current_stat is not None and stat.S_ISDIR(current_stat.st_mode): 

2920 # Already a directory, just ensure .git file exists 

2921 ensure_submodule_placeholder(repo, path) 

2922 else: 

2923 # Remove whatever is there and create submodule 

2924 if current_stat is not None: 

2925 _remove_file_with_readonly_handling(full_path) 

2926 ensure_submodule_placeholder(repo, path) 

2927 

2928 st = os.lstat(full_path) 

2929 assert entry.sha is not None 

2930 index[path] = index_entry_from_stat(st, entry.sha) 

2931 

2932 

2933def _transition_to_file( 

2934 object_store: "BaseObjectStore", 

2935 path: bytes, 

2936 full_path: bytes, 

2937 current_stat: os.stat_result | None, 

2938 entry: IndexEntry | TreeEntry, 

2939 index: Index, 

2940 honor_filemode: bool, 

2941 symlink_fn: Callable[ 

2942 [str | bytes | os.PathLike[str], str | bytes | os.PathLike[str]], None 

2943 ] 

2944 | None, 

2945 blob_normalizer: "FilterBlobNormalizer | None", 

2946 tree_encoding: str = "utf-8", 

2947) -> None: 

2948 """Transition any type to regular file or symlink.""" 

2949 assert entry.sha is not None and entry.mode is not None 

2950 # Check if we need to update 

2951 if ( 

2952 current_stat is not None 

2953 and stat.S_ISREG(current_stat.st_mode) 

2954 and not stat.S_ISLNK(entry.mode) 

2955 ): 

2956 # File to file - check if update needed 

2957 file_matches = _check_file_matches( 

2958 object_store, 

2959 full_path, 

2960 entry.sha, 

2961 entry.mode, 

2962 current_stat, 

2963 honor_filemode, 

2964 blob_normalizer, 

2965 path, 

2966 ) 

2967 needs_update = not file_matches 

2968 elif ( 

2969 current_stat is not None 

2970 and stat.S_ISLNK(current_stat.st_mode) 

2971 and stat.S_ISLNK(entry.mode) 

2972 ): 

2973 # Symlink to symlink - check if update needed 

2974 symlink_matches = _check_symlink_matches(full_path, object_store, entry.sha) 

2975 needs_update = not symlink_matches 

2976 else: 

2977 needs_update = True 

2978 

2979 if not needs_update: 

2980 # Just update index - current_stat should always be valid here since we're not updating 

2981 assert current_stat is not None 

2982 index[path] = index_entry_from_stat(current_stat, entry.sha) 

2983 return 

2984 

2985 # Remove existing entry if needed 

2986 if current_stat is not None and stat.S_ISDIR(current_stat.st_mode): 

2987 # Remove directory 

2988 dir_contents = set(os.listdir(full_path)) 

2989 git_file_name = b".git" if isinstance(full_path, bytes) else ".git" 

2990 

2991 if git_file_name in dir_contents: 

2992 if dir_contents != {git_file_name}: 

2993 raise IsADirectoryError( 

2994 f"Cannot replace submodule with untracked files: {full_path!r}" 

2995 ) 

2996 shutil.rmtree(full_path) 

2997 else: 

2998 try: 

2999 os.rmdir(full_path) 

3000 except OSError as e: 

3001 if e.errno in (errno.ENOTEMPTY, errno.EEXIST): 

3002 raise IsADirectoryError( 

3003 f"Cannot replace non-empty directory with file: {full_path!r}" 

3004 ) 

3005 raise 

3006 elif current_stat is not None: 

3007 _remove_file_with_readonly_handling(full_path) 

3008 

3009 # Ensure parent directory exists 

3010 _ensure_parent_dir_exists(full_path) 

3011 

3012 # Write the file 

3013 blob_obj = object_store[entry.sha] 

3014 assert isinstance(blob_obj, Blob) 

3015 if blob_normalizer: 

3016 blob_obj = blob_normalizer.checkout_normalize(blob_obj, path) 

3017 st = build_file_from_blob( 

3018 blob_obj, 

3019 entry.mode, 

3020 full_path, 

3021 honor_filemode=honor_filemode, 

3022 tree_encoding=tree_encoding, 

3023 symlink_fn=symlink_fn, 

3024 ) 

3025 index[path] = index_entry_from_stat(st, entry.sha) 

3026 

3027 

3028def _transition_to_absent( 

3029 repo: "Repo", 

3030 path: bytes, 

3031 full_path: bytes, 

3032 current_stat: os.stat_result | None, 

3033 index: Index, 

3034) -> None: 

3035 """Remove any type of entry.""" 

3036 if current_stat is None: 

3037 return 

3038 

3039 if stat.S_ISDIR(current_stat.st_mode): 

3040 # Check if it's a submodule directory 

3041 dir_contents = set(os.listdir(full_path)) 

3042 git_file_name = b".git" if isinstance(full_path, bytes) else ".git" 

3043 

3044 if git_file_name in dir_contents and dir_contents == {git_file_name}: 

3045 shutil.rmtree(full_path) 

3046 else: 

3047 try: 

3048 os.rmdir(full_path) 

3049 except OSError as e: 

3050 if e.errno not in (errno.ENOTEMPTY, errno.EEXIST): 

3051 raise 

3052 else: 

3053 _remove_file_with_readonly_handling(full_path) 

3054 

3055 try: 

3056 del index[path] 

3057 except KeyError: 

3058 pass 

3059 

3060 # Try to remove empty parent directories 

3061 _remove_empty_parents( 

3062 full_path, repo.path if isinstance(repo.path, bytes) else repo.path.encode() 

3063 ) 

3064 

3065 

3066def detect_case_only_renames( 

3067 changes: Sequence["TreeChange"], 

3068 config: "Config", 

3069) -> list["TreeChange"]: 

3070 """Detect and transform case-only renames in a list of tree changes. 

3071 

3072 This function identifies file renames that only differ in case (e.g., 

3073 README.txt -> readme.txt) and transforms matching ADD/DELETE pairs into 

3074 CHANGE_RENAME operations. It uses filesystem-appropriate path normalization 

3075 based on the repository configuration. 

3076 

3077 Args: 

3078 changes: List of TreeChange objects representing file changes 

3079 config: Repository configuration object 

3080 

3081 Returns: 

3082 New list of TreeChange objects with case-only renames converted to CHANGE_RENAME 

3083 """ 

3084 from .diff_tree import ( 

3085 CHANGE_ADD, 

3086 CHANGE_COPY, 

3087 CHANGE_DELETE, 

3088 CHANGE_MODIFY, 

3089 CHANGE_RENAME, 

3090 TreeChange, 

3091 ) 

3092 

3093 # Build dictionaries of old and new paths with their normalized forms 

3094 old_paths_normalized = {} 

3095 new_paths_normalized = {} 

3096 old_changes = {} # Map from old path to change object 

3097 new_changes = {} # Map from new path to change object 

3098 

3099 # Get the appropriate normalizer based on config 

3100 normalize_func = get_path_element_normalizer(config) 

3101 

3102 def normalize_path(path: bytes) -> bytes: 

3103 """Normalize entire path using element normalization.""" 

3104 return b"/".join(normalize_func(part) for part in path.split(b"/")) 

3105 

3106 # Pre-normalize all paths once to avoid repeated normalization 

3107 for change in changes: 

3108 if change.type == CHANGE_DELETE and change.old: 

3109 assert change.old.path is not None 

3110 try: 

3111 normalized = normalize_path(change.old.path) 

3112 except UnicodeDecodeError: 

3113 logger.warning( 

3114 "Skipping case-only rename detection for path with invalid UTF-8: %r", 

3115 change.old.path, 

3116 ) 

3117 else: 

3118 old_paths_normalized[normalized] = change.old.path 

3119 old_changes[change.old.path] = change 

3120 elif change.type == CHANGE_RENAME and change.old: 

3121 assert change.old.path is not None 

3122 # Treat RENAME as DELETE + ADD for case-only detection 

3123 try: 

3124 normalized = normalize_path(change.old.path) 

3125 except UnicodeDecodeError: 

3126 logger.warning( 

3127 "Skipping case-only rename detection for path with invalid UTF-8: %r", 

3128 change.old.path, 

3129 ) 

3130 else: 

3131 old_paths_normalized[normalized] = change.old.path 

3132 old_changes[change.old.path] = change 

3133 

3134 if ( 

3135 change.type in (CHANGE_ADD, CHANGE_MODIFY, CHANGE_RENAME, CHANGE_COPY) 

3136 and change.new 

3137 ): 

3138 assert change.new.path is not None 

3139 try: 

3140 normalized = normalize_path(change.new.path) 

3141 except UnicodeDecodeError: 

3142 logger.warning( 

3143 "Skipping case-only rename detection for path with invalid UTF-8: %r", 

3144 change.new.path, 

3145 ) 

3146 else: 

3147 new_paths_normalized[normalized] = change.new.path 

3148 new_changes[change.new.path] = change 

3149 

3150 # Find case-only renames and transform changes 

3151 case_only_renames = set() 

3152 new_rename_changes = [] 

3153 

3154 for norm_path, old_path in old_paths_normalized.items(): 

3155 if norm_path in new_paths_normalized: 

3156 new_path = new_paths_normalized[norm_path] 

3157 if old_path != new_path: 

3158 # Found a case-only rename 

3159 old_change = old_changes[old_path] 

3160 new_change = new_changes[new_path] 

3161 

3162 # Create a CHANGE_RENAME to replace the DELETE and ADD/MODIFY pair 

3163 if new_change.type == CHANGE_ADD: 

3164 # Simple case: DELETE + ADD becomes RENAME 

3165 rename_change = TreeChange( 

3166 CHANGE_RENAME, old_change.old, new_change.new 

3167 ) 

3168 else: 

3169 # Complex case: DELETE + MODIFY becomes RENAME 

3170 # Use the old file from DELETE and new file from MODIFY 

3171 rename_change = TreeChange( 

3172 CHANGE_RENAME, old_change.old, new_change.new 

3173 ) 

3174 

3175 new_rename_changes.append(rename_change) 

3176 

3177 # Mark the old changes for removal 

3178 case_only_renames.add(old_change) 

3179 case_only_renames.add(new_change) 

3180 

3181 # Return new list with original ADD/DELETE changes replaced by renames 

3182 result = [change for change in changes if change not in case_only_renames] 

3183 result.extend(new_rename_changes) 

3184 return result 

3185 

3186 

3187def update_working_tree( 

3188 repo: "Repo", 

3189 old_tree_id: bytes | None, 

3190 new_tree_id: bytes, 

3191 change_iterator: Iterator["TreeChange"], 

3192 honor_filemode: bool = True, 

3193 validate_path_element: Callable[[bytes], bool] | None = None, 

3194 symlink_fn: Callable[ 

3195 [str | bytes | os.PathLike[str], str | bytes | os.PathLike[str]], None 

3196 ] 

3197 | None = None, 

3198 force_remove_untracked: bool = False, 

3199 blob_normalizer: "FilterBlobNormalizer | None" = None, 

3200 tree_encoding: str = "utf-8", 

3201 allow_overwrite_modified: bool = False, 

3202 *, 

3203 config: "Config | None" = None, 

3204) -> None: 

3205 """Update the working tree and index to match a new tree. 

3206 

3207 This function handles: 

3208 - Adding new files 

3209 - Updating modified files 

3210 - Removing deleted files 

3211 - Cleaning up empty directories 

3212 

3213 Args: 

3214 repo: Repository object 

3215 old_tree_id: SHA of the tree before the update 

3216 new_tree_id: SHA of the tree to update to 

3217 change_iterator: Iterator of TreeChange objects to apply 

3218 honor_filemode: An optional flag to honor core.filemode setting 

3219 validate_path_element: Function to validate path elements to check out. 

3220 If None, derived from ``config`` so that ``core.protectNTFS`` and 

3221 ``core.protectHFS`` are honored by default. 

3222 symlink_fn: Function to use for creating symlinks 

3223 force_remove_untracked: If True, remove files that exist in working 

3224 directory but not in target tree, even if old_tree_id is None 

3225 blob_normalizer: An optional BlobNormalizer to use for converting line 

3226 endings when writing blobs to the working directory. 

3227 tree_encoding: Encoding used for tree paths (default: utf-8) 

3228 allow_overwrite_modified: If False, raise an error when attempting to 

3229 overwrite files that have been modified compared to old_tree_id 

3230 config: Repository configuration. If None, falls back to 

3231 ``repo.get_config_stack()``. 

3232 """ 

3233 from .diff_tree import ( 

3234 CHANGE_ADD, 

3235 CHANGE_COPY, 

3236 CHANGE_DELETE, 

3237 CHANGE_MODIFY, 

3238 CHANGE_RENAME, 

3239 CHANGE_UNCHANGED, 

3240 ) 

3241 

3242 if force_remove_untracked: 

3243 import warnings 

3244 

3245 warnings.warn( 

3246 "force_remove_untracked is a no-op and will be removed in a future release", 

3247 DeprecationWarning, 

3248 ) 

3249 repo_path = repo.path if isinstance(repo.path, bytes) else repo.path.encode() 

3250 if config is None: 

3251 config = repo.get_config_stack() 

3252 

3253 if validate_path_element is None: 

3254 # Derive the validator from config so callers cannot accidentally 

3255 # skip ``core.protectNTFS``/``core.protectHFS`` enforcement by 

3256 # omitting this argument. 

3257 validate_path_element = get_path_element_validator(config) 

3258 

3259 index = repo.open_index(config=config) 

3260 

3261 # Convert iterator to list since we need multiple passes 

3262 changes = list(change_iterator) 

3263 

3264 # Transform case-only renames on case-insensitive filesystems 

3265 import platform 

3266 

3267 default_ignore_case = platform.system() in ("Windows", "Darwin") 

3268 config = repo.get_config() 

3269 ignore_case = config.get_boolean((b"core",), b"ignorecase", default_ignore_case) 

3270 

3271 if ignore_case: 

3272 config = repo.get_config() 

3273 changes = detect_case_only_renames(changes, config) 

3274 

3275 # Check for path conflicts where files need to become directories 

3276 paths_becoming_dirs = set() 

3277 for change in changes: 

3278 if change.type in (CHANGE_ADD, CHANGE_MODIFY, CHANGE_RENAME, CHANGE_COPY): 

3279 assert change.new is not None 

3280 path = change.new.path 

3281 assert path is not None 

3282 if b"/" in path: # This is a file inside a directory 

3283 # Check if any parent path exists as a file in the old tree or changes 

3284 parts = path.split(b"/") 

3285 for i in range(1, len(parts)): 

3286 parent = b"/".join(parts[:i]) 

3287 # See if this parent path is being deleted (was a file, becoming a dir) 

3288 for other_change in changes: 

3289 if ( 

3290 other_change.type == CHANGE_DELETE 

3291 and other_change.old 

3292 and other_change.old.path == parent 

3293 ): 

3294 paths_becoming_dirs.add(parent) 

3295 

3296 # Check if any path that needs to become a directory has been modified 

3297 for path in paths_becoming_dirs: 

3298 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3299 try: 

3300 current_stat = os.lstat(full_path) 

3301 except FileNotFoundError: 

3302 continue # File doesn't exist, nothing to check 

3303 except OSError as e: 

3304 raise OSError( 

3305 f"Cannot access {path.decode('utf-8', errors='replace')}: {e}" 

3306 ) from e 

3307 

3308 if stat.S_ISREG(current_stat.st_mode): 

3309 # Find the old entry for this path 

3310 old_change = None 

3311 for change in changes: 

3312 if ( 

3313 change.type == CHANGE_DELETE 

3314 and change.old 

3315 and change.old.path == path 

3316 ): 

3317 old_change = change 

3318 break 

3319 

3320 if old_change: 

3321 # Check if file has been modified 

3322 assert old_change.old is not None 

3323 assert ( 

3324 old_change.old.sha is not None and old_change.old.mode is not None 

3325 ) 

3326 file_matches = _check_file_matches( 

3327 repo.object_store, 

3328 full_path, 

3329 old_change.old.sha, 

3330 old_change.old.mode, 

3331 current_stat, 

3332 honor_filemode, 

3333 blob_normalizer, 

3334 path, 

3335 ) 

3336 if not file_matches: 

3337 raise OSError( 

3338 f"Cannot replace modified file with directory: {path!r}" 

3339 ) 

3340 

3341 # Check for uncommitted modifications before making any changes 

3342 if not allow_overwrite_modified and old_tree_id: 

3343 for change in changes: 

3344 # Only check files that are being modified or deleted 

3345 if change.type in (CHANGE_MODIFY, CHANGE_DELETE) and change.old: 

3346 path = change.old.path 

3347 assert path is not None 

3348 if not validate_path(path, validate_path_element): 

3349 continue 

3350 

3351 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3352 try: 

3353 current_stat = os.lstat(full_path) 

3354 except FileNotFoundError: 

3355 continue # File doesn't exist, nothing to check 

3356 except OSError as e: 

3357 raise OSError( 

3358 f"Cannot access {path.decode('utf-8', errors='replace')}: {e}" 

3359 ) from e 

3360 

3361 if stat.S_ISREG(current_stat.st_mode): 

3362 # Check if working tree file differs from old tree 

3363 assert change.old.sha is not None and change.old.mode is not None 

3364 file_matches = _check_file_matches( 

3365 repo.object_store, 

3366 full_path, 

3367 change.old.sha, 

3368 change.old.mode, 

3369 current_stat, 

3370 honor_filemode, 

3371 blob_normalizer, 

3372 path, 

3373 ) 

3374 if not file_matches: 

3375 from .errors import WorkingTreeModifiedError 

3376 

3377 raise WorkingTreeModifiedError( 

3378 f"Your local changes to '{path.decode('utf-8', errors='replace')}' " 

3379 f"would be overwritten by checkout. " 

3380 f"Please commit your changes or stash them before you switch branches." 

3381 ) 

3382 

3383 # Apply the changes 

3384 for change in changes: 

3385 if change.type in (CHANGE_DELETE, CHANGE_RENAME): 

3386 # Remove file/directory 

3387 assert change.old is not None and change.old.path is not None 

3388 path = change.old.path 

3389 if not validate_path(path, validate_path_element): 

3390 continue 

3391 

3392 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3393 try: 

3394 delete_stat: os.stat_result | None = os.lstat(full_path) 

3395 except FileNotFoundError: 

3396 delete_stat = None 

3397 except OSError as e: 

3398 raise OSError( 

3399 f"Cannot access {path.decode('utf-8', errors='replace')}: {e}" 

3400 ) from e 

3401 

3402 _transition_to_absent(repo, path, full_path, delete_stat, index) 

3403 

3404 if change.type in ( 

3405 CHANGE_ADD, 

3406 CHANGE_MODIFY, 

3407 CHANGE_UNCHANGED, 

3408 CHANGE_COPY, 

3409 CHANGE_RENAME, 

3410 ): 

3411 # Add or modify file 

3412 assert ( 

3413 change.new is not None 

3414 and change.new.path is not None 

3415 and change.new.mode is not None 

3416 ) 

3417 path = change.new.path 

3418 # Validate as we go and abort on the first invalid path, 

3419 # leaving any changes already applied in place. 

3420 verify_tree_path(path, validate_path_element, [], repo_path) 

3421 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3422 try: 

3423 modify_stat: os.stat_result | None = os.lstat(full_path) 

3424 except FileNotFoundError: 

3425 modify_stat = None 

3426 except OSError as e: 

3427 raise OSError( 

3428 f"Cannot access {path.decode('utf-8', errors='replace')}: {e}" 

3429 ) from e 

3430 

3431 if S_ISGITLINK(change.new.mode): 

3432 _transition_to_submodule( 

3433 repo, path, full_path, modify_stat, change.new, index 

3434 ) 

3435 else: 

3436 _transition_to_file( 

3437 repo.object_store, 

3438 path, 

3439 full_path, 

3440 modify_stat, 

3441 change.new, 

3442 index, 

3443 honor_filemode, 

3444 symlink_fn, 

3445 blob_normalizer, 

3446 tree_encoding, 

3447 ) 

3448 

3449 index.write() 

3450 

3451 

3452def _stat_matches_entry( 

3453 st: os.stat_result, entry: IndexEntry, trust_ctime: bool = True 

3454) -> bool: 

3455 """Check if filesystem stat matches index entry stat. 

3456 

3457 This is used to determine if a file might have changed without reading its content. 

3458 Git uses this optimization to avoid expensive filter operations on unchanged files. 

3459 

3460 Args: 

3461 st: Filesystem stat result 

3462 entry: Index entry to compare against 

3463 trust_ctime: If True, also check ctime (default: True, matching Git behavior) 

3464 Returns: True if stat matches and file is likely unchanged 

3465 """ 

3466 # Compare change time (ctime) if trust_ctime is enabled 

3467 if trust_ctime: 

3468 # Get entry ctime with nanosecond precision if available 

3469 if isinstance(entry.ctime, tuple): 

3470 entry_ctime_sec = entry.ctime[0] 

3471 entry_ctime_nsec = entry.ctime[1] 

3472 else: 

3473 entry_ctime_sec = int(entry.ctime) 

3474 entry_ctime_nsec = 0 

3475 

3476 if hasattr(st, "st_ctime_ns"): 

3477 # Use nanosecond precision when available 

3478 st_ctime_nsec = st.st_ctime_ns 

3479 entry_ctime_nsec_total = entry_ctime_sec * 1_000_000_000 + entry_ctime_nsec 

3480 if st_ctime_nsec != entry_ctime_nsec_total: 

3481 return False 

3482 else: 

3483 # Fall back to second precision 

3484 if int(st.st_ctime) != entry_ctime_sec: 

3485 return False 

3486 

3487 # Get entry mtime with nanosecond precision if available 

3488 if isinstance(entry.mtime, tuple): 

3489 entry_mtime_sec = entry.mtime[0] 

3490 entry_mtime_nsec = entry.mtime[1] 

3491 else: 

3492 entry_mtime_sec = int(entry.mtime) 

3493 entry_mtime_nsec = 0 

3494 

3495 # Compare modification time with nanosecond precision if available 

3496 # This is important for fast workflows (e.g., stash) where files can be 

3497 # modified multiple times within the same second 

3498 if hasattr(st, "st_mtime_ns"): 

3499 # Use nanosecond precision when available 

3500 st_mtime_nsec = st.st_mtime_ns 

3501 entry_mtime_nsec_total = entry_mtime_sec * 1_000_000_000 + entry_mtime_nsec 

3502 if st_mtime_nsec != entry_mtime_nsec_total: 

3503 return False 

3504 else: 

3505 # Fall back to second precision 

3506 if int(st.st_mtime) != entry_mtime_sec: 

3507 return False 

3508 

3509 # Compare file size 

3510 if st.st_size != entry.size: 

3511 return False 

3512 

3513 # If all checks pass, file is likely unchanged 

3514 return True 

3515 

3516 

3517def _check_entry_for_changes( 

3518 tree_path: bytes, 

3519 entry: IndexEntry | ConflictedIndexEntry, 

3520 root_path: bytes, 

3521 filter_blob_callback: Callable[[Blob, bytes], Blob] | None = None, 

3522 trust_ctime: bool = True, 

3523 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3524 refresh_stat: list[tuple[bytes, os.stat_result]] | None = None, 

3525) -> bytes | None: 

3526 """Check a single index entry for changes. 

3527 

3528 Args: 

3529 tree_path: Path in the tree 

3530 entry: Index entry to check 

3531 root_path: Root filesystem path 

3532 filter_blob_callback: Optional callback to filter blobs 

3533 trust_ctime: If True, use ctime for change detection (default: True) 

3534 object_format: Object format to hash the working tree blob with 

3535 refresh_stat: If provided, entries with drifted stat but matching 

3536 content have their (tree_path, stat_result) appended so the 

3537 caller can update the index stat cache in a single pass. 

3538 Returns: tree_path if changed, None otherwise 

3539 """ 

3540 if isinstance(entry, ConflictedIndexEntry): 

3541 # Conflicted files are always unstaged 

3542 return tree_path 

3543 

3544 full_path = _tree_to_fs_path(root_path, tree_path) 

3545 try: 

3546 st = os.lstat(full_path) 

3547 if stat.S_ISDIR(st.st_mode): 

3548 if _has_directory_changed(tree_path, entry): 

3549 return tree_path 

3550 return None 

3551 

3552 if not stat.S_ISREG(st.st_mode) and not stat.S_ISLNK(st.st_mode): 

3553 return None 

3554 

3555 # Optimization: If stat matches index entry (mtime and size unchanged), 

3556 # we can skip reading and filtering the file entirely. This is a significant 

3557 # performance improvement for repositories with many unchanged files. 

3558 # Even with filters (e.g., LFS), if the file hasn't been modified (stat unchanged), 

3559 # the filter output would be the same, so we can safely skip the expensive 

3560 # filter operation. This addresses performance issues with LFS repositories 

3561 # where filter operations can be very slow. 

3562 if _stat_matches_entry(st, entry, trust_ctime): 

3563 return None 

3564 

3565 blob = blob_from_path_and_stat(full_path, st) 

3566 

3567 if filter_blob_callback is not None: 

3568 blob = filter_blob_callback(blob, tree_path) 

3569 except FileNotFoundError: 

3570 # The file was removed, so we assume that counts as 

3571 # different from whatever file used to exist. 

3572 return tree_path 

3573 else: 

3574 if blob.get_id(object_format) != entry.sha: 

3575 return tree_path 

3576 if refresh_stat is not None: 

3577 refresh_stat.append((tree_path, st)) 

3578 return None 

3579 

3580 

3581def get_unstaged_changes( 

3582 index: Index, 

3583 root_path: str | bytes, 

3584 filter_blob_callback: Callable[..., Any] | None = None, 

3585 preload_index: bool = False, 

3586 trust_ctime: bool = True, 

3587 max_stat: int | None = None, 

3588 refresh_stat: list[tuple[bytes, os.stat_result]] | None = None, 

3589) -> Generator[bytes, None, None]: 

3590 """Walk through an index and check for differences against working tree. 

3591 

3592 Args: 

3593 index: index to check 

3594 root_path: path in which to find files 

3595 filter_blob_callback: Optional callback to filter blobs 

3596 preload_index: If True, use parallel threads to check files (requires threading support) 

3597 trust_ctime: If True, use ctime for change detection (default: True) 

3598 max_stat: If set, limit the number of stat operations performed. 

3599 When the limit is reached, remaining files are assumed unchanged. 

3600 refresh_stat: If provided, entries whose stat has drifted but whose 

3601 content is unchanged are appended as ``(tree_path, stat_result)`` 

3602 pairs so the caller can update the index stat cache without a 

3603 second pass. 

3604 Returns: iterator over paths with unstaged changes 

3605 """ 

3606 # For each entry in the index check the sha1 & ensure not staged 

3607 if not isinstance(root_path, bytes): 

3608 root_path = os.fsencode(root_path) 

3609 

3610 stat_count = 0 

3611 

3612 if preload_index: 

3613 # Use parallel processing for better performance on slow filesystems 

3614 try: 

3615 import multiprocessing 

3616 from concurrent.futures import ThreadPoolExecutor 

3617 except ImportError: 

3618 # If threading is not available, fall back to serial processing 

3619 preload_index = False 

3620 else: 

3621 # Collect all entries first 

3622 entries = list(index.iteritems()) 

3623 

3624 if max_stat is not None: 

3625 # When max_stat is set, limit the entries we process 

3626 entries = entries[:max_stat] 

3627 

3628 # Use number of CPUs but cap at 8 threads to avoid overhead 

3629 num_workers = min(multiprocessing.cpu_count(), 8) 

3630 

3631 # Per-thread refresh accumulators so appends stay lock-free, 

3632 # then merged into the caller's list once workers finish. 

3633 worker_refresh: list[list[tuple[bytes, os.stat_result]] | None] = [ 

3634 [] if refresh_stat is not None else None for _ in entries 

3635 ] 

3636 

3637 # Process entries in parallel 

3638 with ThreadPoolExecutor(max_workers=num_workers) as executor: 

3639 # Submit all tasks 

3640 futures = [ 

3641 executor.submit( 

3642 _check_entry_for_changes, 

3643 tree_path, 

3644 entry, 

3645 root_path, 

3646 filter_blob_callback, 

3647 trust_ctime, 

3648 index.object_format, 

3649 worker_refresh[i], 

3650 ) 

3651 for i, (tree_path, entry) in enumerate(entries) 

3652 ] 

3653 

3654 # Yield results as they complete 

3655 for future in futures: 

3656 result = future.result() 

3657 if result is not None: 

3658 yield result 

3659 

3660 if refresh_stat is not None: 

3661 for bucket in worker_refresh: 

3662 if bucket: 

3663 refresh_stat.extend(bucket) 

3664 

3665 if not preload_index: 

3666 # Serial processing 

3667 for tree_path, entry in index.iteritems(): 

3668 if max_stat is not None and stat_count >= max_stat: 

3669 return 

3670 result = _check_entry_for_changes( 

3671 tree_path, 

3672 entry, 

3673 root_path, 

3674 filter_blob_callback, 

3675 trust_ctime, 

3676 index.object_format, 

3677 refresh_stat, 

3678 ) 

3679 stat_count += 1 

3680 if result is not None: 

3681 yield result 

3682 

3683 

3684def _decode_utf8_with_fallback(data: bytes) -> str: 

3685 """Decode bytes as UTF-8, with lossy fallbacks for invalid sequences. 

3686 

3687 Mirrors the behaviour of git-for-windows's ``xutftowcsn`` (in 

3688 ``compat/mingw.c``) so that tree paths containing legacy-encoded or 

3689 otherwise invalid UTF-8 produce the same on-disk filename as C git. 

3690 

3691 Rules: 

3692 * Valid UTF-8 (1-4 byte sequences, excluding overlongs and codepoints 

3693 > U+10FFFF) is decoded normally. 

3694 * Invalid bytes in 0xa0-0xff map 1:1 to U+00A0-U+00FF. 

3695 * Invalid bytes in 0x80-0x9f are expanded to two lowercase ASCII hex 

3696 digits (e.g. byte 0x80 -> "80"). 

3697 * Truncated multi-byte sequences and overlong/out-of-range encodings 

3698 cause the lead byte to fall through to the above invalid-byte rules 

3699 (the trail bytes are re-evaluated on the next iteration). 

3700 """ 

3701 out: list[str] = [] 

3702 i = 0 

3703 n = len(data) 

3704 while i < n: 

3705 c = data[i] 

3706 if c < 0x80: 

3707 out.append(chr(c)) 

3708 i += 1 

3709 elif 0xC2 <= c < 0xE0 and i + 1 < n and (data[i + 1] & 0xC0) == 0x80: 

3710 cp = ((c & 0x1F) << 6) | (data[i + 1] & 0x3F) 

3711 out.append(chr(cp)) 

3712 i += 2 

3713 elif ( 

3714 0xE0 <= c < 0xF0 

3715 and i + 2 < n 

3716 and not (c == 0xE0 and data[i + 1] < 0xA0) 

3717 and (data[i + 1] & 0xC0) == 0x80 

3718 and (data[i + 2] & 0xC0) == 0x80 

3719 ): 

3720 cp = ((c & 0x0F) << 12) | ((data[i + 1] & 0x3F) << 6) | (data[i + 2] & 0x3F) 

3721 out.append(chr(cp)) 

3722 i += 3 

3723 elif ( 

3724 0xF0 <= c < 0xF5 

3725 and i + 3 < n 

3726 and not (c == 0xF0 and data[i + 1] < 0x90) 

3727 and not (c == 0xF4 and data[i + 1] >= 0x90) 

3728 and (data[i + 1] & 0xC0) == 0x80 

3729 and (data[i + 2] & 0xC0) == 0x80 

3730 and (data[i + 3] & 0xC0) == 0x80 

3731 ): 

3732 cp = ( 

3733 ((c & 0x07) << 18) 

3734 | ((data[i + 1] & 0x3F) << 12) 

3735 | ((data[i + 2] & 0x3F) << 6) 

3736 | (data[i + 3] & 0x3F) 

3737 ) 

3738 out.append(chr(cp)) 

3739 i += 4 

3740 elif c >= 0xA0: 

3741 out.append(chr(c)) 

3742 i += 1 

3743 else: 

3744 out.append(f"{c:02x}") 

3745 i += 1 

3746 return "".join(out) 

3747 

3748 

3749def _tree_to_fs_path( 

3750 root_path: bytes, tree_path: bytes, tree_encoding: str = "utf-8" 

3751) -> bytes: 

3752 """Convert a git tree path to a file system path. 

3753 

3754 Args: 

3755 root_path: Root filesystem path 

3756 tree_path: Git tree path as bytes (encoded with tree_encoding) 

3757 tree_encoding: Encoding used for tree paths (default: utf-8) 

3758 

3759 Returns: File system path. 

3760 """ 

3761 assert isinstance(tree_path, bytes) 

3762 if os_sep_bytes != b"/": 

3763 sep_corrected_path = tree_path.replace(b"/", os_sep_bytes) 

3764 else: 

3765 sep_corrected_path = tree_path 

3766 

3767 # On Windows, decode tree-encoded bytes to a str so they can flow into 

3768 # the wide-char Win32 APIs via Python's filesystem layer. For UTF-8 

3769 # (the default tree encoding) we use a lossy decoder that matches C 

3770 # git's xutftowcsn fallbacks; for other encodings we let UnicodeDecodeError 

3771 # propagate rather than silently producing a corrupt path. 

3772 if sys.platform == "win32": 

3773 if tree_encoding == "utf-8": 

3774 tree_path_str = _decode_utf8_with_fallback(sep_corrected_path) 

3775 else: 

3776 tree_path_str = sep_corrected_path.decode(tree_encoding) 

3777 sep_corrected_path = os.fsencode(tree_path_str) 

3778 

3779 return os.path.join(root_path, sep_corrected_path) 

3780 

3781 

3782def _fs_to_tree_path(fs_path: str | bytes, tree_encoding: str = "utf-8") -> bytes: 

3783 """Convert a file system path to a git tree path. 

3784 

3785 Args: 

3786 fs_path: File system path. 

3787 tree_encoding: Encoding to use for tree paths (default: utf-8) 

3788 

3789 Returns: Git tree path as bytes (encoded with tree_encoding) 

3790 """ 

3791 if not isinstance(fs_path, bytes): 

3792 fs_path_bytes = os.fsencode(fs_path) 

3793 else: 

3794 fs_path_bytes = fs_path 

3795 

3796 # On Windows the on-disk filename is a UTF-16 wide string; Python gives 

3797 # us either str (already decoded) or bytes encoded via the filesystem 

3798 # codec. Normalise to str, then encode under the tree encoding so the 

3799 # resulting tree path is plain UTF-8. This matches C git's xwcstoutf, 

3800 # which is just WideCharToMultiByte(CP_UTF8); it makes no attempt to 

3801 # reverse the xutftowcsn fallbacks, so a file that was checked out from 

3802 # a tree path with invalid UTF-8 will read back as the lossy form (the 

3803 # same divergence C git exhibits, documented as a one-way mapping). 

3804 if sys.platform == "win32": 

3805 fs_path_str = os.fsdecode(fs_path_bytes) 

3806 fs_path_bytes = fs_path_str.encode(tree_encoding) 

3807 

3808 if os_sep_bytes != b"/": 

3809 tree_path = fs_path_bytes.replace(os_sep_bytes, b"/") 

3810 else: 

3811 tree_path = fs_path_bytes 

3812 return tree_path 

3813 

3814 

3815def index_entry_from_directory(st: os.stat_result, path: bytes) -> IndexEntry | None: 

3816 """Create an index entry for a directory. 

3817 

3818 This is only used for submodules (directories containing .git). 

3819 

3820 Args: 

3821 st: Stat result for the directory 

3822 path: Path to the directory 

3823 

3824 Returns: 

3825 IndexEntry for a submodule, or None if not a submodule 

3826 """ 

3827 if os.path.exists(os.path.join(path, b".git")): 

3828 head = read_submodule_head(path) 

3829 if head is None: 

3830 return None 

3831 return index_entry_from_stat(st, head, mode=S_IFGITLINK) 

3832 return None 

3833 

3834 

3835def index_entry_from_path( 

3836 path: bytes, 

3837 object_store: ObjectContainer | None = None, 

3838 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3839) -> IndexEntry | None: 

3840 """Create an index from a filesystem path. 

3841 

3842 This returns an index value for files, symlinks 

3843 and tree references. for directories and 

3844 non-existent files it returns None 

3845 

3846 Args: 

3847 path: Path to create an index entry for 

3848 object_store: Optional object store to 

3849 save new blobs in 

3850 object_format: Object format to name new blobs with 

3851 Returns: An index entry; None for directories 

3852 """ 

3853 assert isinstance(path, bytes) 

3854 st = os.lstat(path) 

3855 if stat.S_ISDIR(st.st_mode): 

3856 return index_entry_from_directory(st, path) 

3857 

3858 if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode): 

3859 blob = blob_from_path_and_stat(path, st) 

3860 if object_store is not None: 

3861 object_store.add_object(blob) 

3862 return index_entry_from_stat(st, ObjectID(blob.get_id(object_format))) 

3863 

3864 return None 

3865 

3866 

3867def iter_fresh_entries( 

3868 paths: Iterable[bytes], 

3869 root_path: bytes, 

3870 object_store: ObjectContainer | None = None, 

3871 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3872) -> Iterator[tuple[bytes, IndexEntry | None]]: 

3873 """Iterate over current versions of index entries on disk. 

3874 

3875 Args: 

3876 paths: Paths to iterate over 

3877 root_path: Root path to access from 

3878 object_store: Optional store to save new blobs in 

3879 object_format: Object format to name new blobs with 

3880 Returns: Iterator over path, index_entry 

3881 """ 

3882 for path in paths: 

3883 p = _tree_to_fs_path(root_path, path) 

3884 try: 

3885 entry = index_entry_from_path( 

3886 p, object_store=object_store, object_format=object_format 

3887 ) 

3888 except (FileNotFoundError, IsADirectoryError): 

3889 entry = None 

3890 yield path, entry 

3891 

3892 

3893def iter_fresh_objects( 

3894 paths: Iterable[bytes], 

3895 root_path: bytes, 

3896 include_deleted: bool = False, 

3897 object_store: ObjectContainer | None = None, 

3898 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3899) -> Iterator[tuple[bytes, ObjectID | None, int | None]]: 

3900 """Iterate over versions of objects on disk referenced by index. 

3901 

3902 Args: 

3903 paths: Paths to check 

3904 root_path: Root path to access from 

3905 include_deleted: Include deleted entries with sha and 

3906 mode set to None 

3907 object_store: Optional object store to report new items to 

3908 object_format: Object format to name new blobs with 

3909 Returns: Iterator over path, sha, mode 

3910 """ 

3911 for path, entry in iter_fresh_entries( 

3912 paths, root_path, object_store=object_store, object_format=object_format 

3913 ): 

3914 if entry is None: 

3915 if include_deleted: 

3916 yield path, None, None 

3917 else: 

3918 yield path, entry.sha, cleanup_mode(entry.mode) 

3919 

3920 

3921def refresh_index(index: Index, root_path: bytes) -> None: 

3922 """Refresh the contents of an index. 

3923 

3924 This is the equivalent to running 'git commit -a'. 

3925 

3926 Args: 

3927 index: Index to update 

3928 root_path: Root filesystem path 

3929 """ 

3930 for path, entry in iter_fresh_entries( 

3931 index, root_path, object_format=index.object_format 

3932 ): 

3933 if entry: 

3934 index[path] = entry 

3935 

3936 

3937def apply_stat_refresh( 

3938 index: Index, refreshed: Iterable[tuple[bytes, os.stat_result]] 

3939) -> bool: 

3940 """Apply refreshed stat info collected during an index walk. 

3941 

3942 ``refreshed`` should be the list populated by passing ``refresh_stat`` 

3943 into `get_unstaged_changes`: it contains (tree_path, stat_result) 

3944 pairs for entries whose on-disk stat drifted but whose content still 

3945 matches the index entry sha. 

3946 

3947 Returns True if any entries were updated (caller may want to write 

3948 the index). 

3949 """ 

3950 updated = False 

3951 for tree_path, st in refreshed: 

3952 entry = index[tree_path] 

3953 if isinstance(entry, ConflictedIndexEntry): 

3954 continue 

3955 index[tree_path] = index_entry_from_stat(st, entry.sha, mode=entry.mode) 

3956 updated = True 

3957 return updated 

3958 

3959 

3960class locked_index: 

3961 """Lock the index while making modifications. 

3962 

3963 Works as a context manager. 

3964 """ 

3965 

3966 _file: "_GitFile" 

3967 

3968 def __init__( 

3969 self, 

3970 path: bytes | str, 

3971 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3972 ) -> None: 

3973 """Initialize locked_index. 

3974 

3975 Args: 

3976 path: Path to the index file 

3977 object_format: Object format of the repository owning the index 

3978 """ 

3979 self._path = path 

3980 self._object_format = object_format 

3981 

3982 def __enter__(self) -> Index: 

3983 """Enter context manager and lock index.""" 

3984 f = GitFile(self._path, "wb") 

3985 self._file = f 

3986 self._index = Index(self._path, object_format=self._object_format) 

3987 return self._index 

3988 

3989 def __exit__( 

3990 self, 

3991 exc_type: type | None, 

3992 exc_value: BaseException | None, 

3993 traceback: types.TracebackType | None, 

3994 ) -> None: 

3995 """Exit context manager and unlock index.""" 

3996 if exc_type is not None: 

3997 self._file.abort() 

3998 return 

3999 try: 

4000 f = IndexChecksumWriter(self._file, self._object_format) 

4001 write_index_dict(f, self._index._byname, object_format=self._object_format) 

4002 except BaseException: 

4003 self._file.abort() 

4004 else: 

4005 f.close()