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

1522 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 "unchanged_entry_flags", 

93 "update_working_tree", 

94 "validate_path", 

95 "validate_path_element_default", 

96 "validate_path_element_hfs", 

97 "validate_path_element_ntfs", 

98 "verify_leading_dirs", 

99 "verify_tree_path", 

100 "write_cache_entry", 

101 "write_cache_time", 

102 "write_index", 

103 "write_index_dict", 

104 "write_index_extension", 

105] 

106 

107import errno 

108import logging 

109import os 

110import shutil 

111import stat 

112import struct 

113import sys 

114import types 

115from collections.abc import ( 

116 Callable, 

117 Generator, 

118 Iterable, 

119 Iterator, 

120 Mapping, 

121 Sequence, 

122 Set, 

123) 

124from dataclasses import dataclass 

125from enum import Enum 

126from typing import ( 

127 TYPE_CHECKING, 

128 Any, 

129 Protocol, 

130) 

131 

132if TYPE_CHECKING: 

133 from .config import Config 

134 from .diff_tree import TreeChange 

135 from .file import _GitFile 

136 from .filters import FilterBlobNormalizer 

137 from .object_store import BaseObjectStore 

138 from .repo import Repo 

139 

140from . import _deprecated_aliases 

141from .errors import ChecksumMismatch 

142from .file import GitFile, SharedPerm 

143from .object_format import DEFAULT_OBJECT_FORMAT, ObjectFormat 

144from .object_store import iter_tree_contents 

145from .objects import ( 

146 S_IFGITLINK, 

147 S_ISGITLINK, 

148 Blob, 

149 ObjectID, 

150 RawObjectID, 

151 Tree, 

152 TreeEntry, 

153 hex_to_sha, 

154 sha_to_hex, 

155) 

156from .pack import ObjectContainer 

157 

158logger = logging.getLogger(__name__) 

159 

160 

161__getattr__ = _deprecated_aliases( 

162 __name__, 

163 { 

164 "SHA1Reader": "dulwich.pack.SHA1Reader", 

165 "SHA1Writer": "dulwich.pack.SHA1Writer", 

166 }, 

167) 

168 

169 

170# Type alias for recursive tree structure used in commit_tree 

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

172 

173# 2-bit stage (during merge) 

174FLAG_STAGEMASK = 0x3000 

175FLAG_STAGESHIFT = 12 

176FLAG_NAMEMASK = 0x0FFF 

177 

178# assume-valid 

179FLAG_VALID = 0x8000 

180 

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

182FLAG_EXTENDED = 0x4000 

183 

184# used by sparse checkout 

185EXTENDED_FLAG_SKIP_WORKTREE = 0x4000 

186 

187# used by "git add -N" 

188EXTENDED_FLAG_INTEND_TO_ADD = 0x2000 

189 

190DEFAULT_VERSION = 2 

191 

192# Index extension signatures 

193TREE_EXTENSION = b"TREE" 

194REUC_EXTENSION = b"REUC" 

195UNTR_EXTENSION = b"UNTR" 

196EOIE_EXTENSION = b"EOIE" 

197IEOT_EXTENSION = b"IEOT" 

198SDIR_EXTENSION = b"sdir" # Sparse directory extension 

199 

200 

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

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

203 

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

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

206 

207 Args: 

208 value: Integer to encode 

209 Returns: 

210 Encoded bytes 

211 """ 

212 if value == 0: 

213 return b"\x00" 

214 

215 result = [] 

216 while value > 0: 

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

218 value >>= 7 

219 if value > 0: 

220 byte |= 0x80 # Set continuation bit 

221 result.append(byte) 

222 

223 return bytes(result) 

224 

225 

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

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

228 

229 Args: 

230 data: Bytes to decode from 

231 offset: Starting offset in data 

232 Returns: 

233 tuple of (decoded_value, new_offset) 

234 """ 

235 value = 0 

236 shift = 0 

237 pos = offset 

238 

239 while pos < len(data): 

240 byte = data[pos] 

241 pos += 1 

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

243 shift += 7 

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

245 break 

246 

247 return value, pos 

248 

249 

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

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

252 

253 Args: 

254 path: Path to compress 

255 previous_path: Previous path for comparison 

256 Returns: 

257 Compressed path data (varint prefix_len + suffix) 

258 """ 

259 # Find the common prefix length 

260 common_len = 0 

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

262 

263 for i in range(min_len): 

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

265 common_len += 1 

266 else: 

267 break 

268 

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

270 # to get the common prefix 

271 remove_len = len(previous_path) - common_len 

272 

273 # The suffix to append 

274 suffix = path[common_len:] 

275 

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

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

278 

279 

280def _decompress_path( 

281 data: bytes, offset: int, previous_path: bytes 

282) -> tuple[bytes, int]: 

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

284 

285 Args: 

286 data: Raw data containing compressed path 

287 offset: Starting offset in data 

288 previous_path: Previous path for decompression 

289 Returns: 

290 tuple of (decompressed_path, new_offset) 

291 """ 

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

293 remove_len, new_offset = _decode_varint(data, offset) 

294 

295 # Find the NUL terminator for the suffix 

296 suffix_start = new_offset 

297 suffix_end = suffix_start 

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

299 suffix_end += 1 

300 

301 if suffix_end >= len(data): 

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

303 

304 suffix = data[suffix_start:suffix_end] 

305 new_offset = suffix_end + 1 # Skip the NUL terminator 

306 

307 # Reconstruct the path 

308 if remove_len > len(previous_path): 

309 raise ValueError( 

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

311 ) 

312 

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

314 path = prefix + suffix 

315 

316 return path, new_offset 

317 

318 

319class _IndexWritable(Protocol): 

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

321 

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

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

324 

325 def tell(self) -> int: 

326 """Return the current stream position.""" 

327 

328 def close(self) -> None: 

329 """Close the stream.""" 

330 

331 

332class _IndexReadable(Protocol): 

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

334 

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

336 """Read up to size bytes.""" 

337 

338 def tell(self) -> int: 

339 """Return the current stream position.""" 

340 

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

342 """Change the stream position.""" 

343 

344 

345def _decompress_path_from_stream( 

346 f: _IndexReadable, previous_path: bytes 

347) -> tuple[bytes, int]: 

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

349 

350 Args: 

351 f: File-like object to read from 

352 previous_path: Previous path for decompression 

353 Returns: 

354 tuple of (decompressed_path, bytes_consumed) 

355 """ 

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

357 remove_len = 0 

358 shift = 0 

359 bytes_consumed = 0 

360 

361 while True: 

362 byte_data = f.read(1) 

363 if not byte_data: 

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

365 byte = byte_data[0] 

366 bytes_consumed += 1 

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

368 shift += 7 

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

370 break 

371 

372 # Read the suffix until NUL terminator 

373 suffix = b"" 

374 while True: 

375 byte_data = f.read(1) 

376 if not byte_data: 

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

378 byte = byte_data[0] 

379 bytes_consumed += 1 

380 if byte == 0: # NUL terminator 

381 break 

382 suffix += bytes([byte]) 

383 

384 # Reconstruct the path 

385 if remove_len > len(previous_path): 

386 raise ValueError( 

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

388 ) 

389 

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

391 path = prefix + suffix 

392 

393 return path, bytes_consumed 

394 

395 

396class Stage(Enum): 

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

398 

399 NORMAL = 0 

400 MERGE_CONFLICT_ANCESTOR = 1 

401 MERGE_CONFLICT_THIS = 2 

402 MERGE_CONFLICT_OTHER = 3 

403 

404 

405@dataclass 

406class SerializedIndexEntry: 

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

408 

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

410 parsed into the more user-friendly IndexEntry format. 

411 """ 

412 

413 name: bytes 

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

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

416 dev: int 

417 ino: int 

418 mode: int 

419 uid: int 

420 gid: int 

421 size: int 

422 sha: ObjectID 

423 flags: int 

424 extended_flags: int 

425 

426 def stage(self) -> Stage: 

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

428 

429 Returns: 

430 Stage enum value indicating merge conflict state 

431 """ 

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

433 

434 def is_sparse_dir(self) -> bool: 

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

436 

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

438 directory tree in a sparse index. It has: 

439 - Directory mode (0o040000) 

440 - SKIP_WORKTREE flag set 

441 - Path ending with '/' 

442 - SHA pointing to a tree object 

443 

444 Returns: 

445 True if entry is a sparse directory entry 

446 """ 

447 return ( 

448 stat.S_ISDIR(self.mode) 

449 and bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

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

451 ) 

452 

453 

454@dataclass 

455class IndexExtension: 

456 """Base class for index extensions.""" 

457 

458 signature: bytes 

459 data: bytes 

460 

461 @classmethod 

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

463 """Create an extension from raw data. 

464 

465 Args: 

466 signature: 4-byte extension signature 

467 data: Extension data 

468 Returns: 

469 Parsed extension object 

470 """ 

471 if signature == TREE_EXTENSION: 

472 return TreeExtension.from_bytes(data) 

473 elif signature == REUC_EXTENSION: 

474 return ResolveUndoExtension.from_bytes(data) 

475 elif signature == UNTR_EXTENSION: 

476 return UntrackedExtension.from_bytes(data) 

477 elif signature == SDIR_EXTENSION: 

478 return SparseDirExtension.from_bytes(data) 

479 else: 

480 # Unknown extension - just store raw data 

481 return cls(signature, data) 

482 

483 def to_bytes(self) -> bytes: 

484 """Serialize extension to bytes.""" 

485 return self.data 

486 

487 

488class TreeExtension(IndexExtension): 

489 """Tree cache extension.""" 

490 

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

492 """Initialize TreeExtension. 

493 

494 Args: 

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

496 """ 

497 self.entries = entries 

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

499 

500 @classmethod 

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

502 """Parse TreeExtension from bytes. 

503 

504 Args: 

505 data: Raw bytes to parse 

506 

507 Returns: 

508 TreeExtension instance 

509 """ 

510 # TODO: Implement tree cache parsing 

511 return cls([]) 

512 

513 def to_bytes(self) -> bytes: 

514 """Serialize TreeExtension to bytes. 

515 

516 Returns: 

517 Serialized extension data 

518 """ 

519 # TODO: Implement tree cache serialization 

520 return b"" 

521 

522 

523class ResolveUndoExtension(IndexExtension): 

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

525 

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

527 """Initialize ResolveUndoExtension. 

528 

529 Args: 

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

531 """ 

532 self.entries = entries 

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

534 

535 @classmethod 

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

537 """Parse ResolveUndoExtension from bytes. 

538 

539 Args: 

540 data: Raw bytes to parse 

541 

542 Returns: 

543 ResolveUndoExtension instance 

544 """ 

545 # TODO: Implement resolve undo parsing 

546 return cls([]) 

547 

548 def to_bytes(self) -> bytes: 

549 """Serialize ResolveUndoExtension to bytes. 

550 

551 Returns: 

552 Serialized extension data 

553 """ 

554 # TODO: Implement resolve undo serialization 

555 return b"" 

556 

557 

558class UntrackedExtension(IndexExtension): 

559 """Untracked cache extension.""" 

560 

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

562 """Initialize UntrackedExtension. 

563 

564 Args: 

565 data: Raw untracked cache data 

566 """ 

567 super().__init__(UNTR_EXTENSION, data) 

568 

569 @classmethod 

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

571 """Parse UntrackedExtension from bytes. 

572 

573 Args: 

574 data: Raw bytes to parse 

575 

576 Returns: 

577 UntrackedExtension instance 

578 """ 

579 return cls(data) 

580 

581 

582class SparseDirExtension(IndexExtension): 

583 """Sparse directory extension. 

584 

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

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

587 the index when this extension is present. 

588 

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

590 """ 

591 

592 def __init__(self) -> None: 

593 """Initialize SparseDirExtension.""" 

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

595 

596 @classmethod 

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

598 """Parse SparseDirExtension from bytes. 

599 

600 Args: 

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

602 

603 Returns: 

604 SparseDirExtension instance 

605 """ 

606 return cls() 

607 

608 def to_bytes(self) -> bytes: 

609 """Serialize SparseDirExtension to bytes. 

610 

611 Returns: 

612 Empty bytes (extension presence is the signal) 

613 """ 

614 return b"" 

615 

616 

617@dataclass 

618class IndexEntry: 

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

620 

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

622 parsed data and convenience methods. 

623 """ 

624 

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

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

627 dev: int 

628 ino: int 

629 mode: int 

630 uid: int 

631 gid: int 

632 size: int 

633 sha: ObjectID 

634 flags: int = 0 

635 extended_flags: int = 0 

636 

637 @classmethod 

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

639 """Create an IndexEntry from a SerializedIndexEntry. 

640 

641 Args: 

642 serialized: SerializedIndexEntry to convert 

643 

644 Returns: 

645 New IndexEntry instance 

646 """ 

647 return cls( 

648 ctime=serialized.ctime, 

649 mtime=serialized.mtime, 

650 dev=serialized.dev, 

651 ino=serialized.ino, 

652 mode=serialized.mode, 

653 uid=serialized.uid, 

654 gid=serialized.gid, 

655 size=serialized.size, 

656 sha=serialized.sha, 

657 flags=serialized.flags, 

658 extended_flags=serialized.extended_flags, 

659 ) 

660 

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

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

663 

664 Args: 

665 name: Path name for the entry 

666 stage: Merge conflict stage 

667 

668 Returns: 

669 SerializedIndexEntry ready for writing to disk 

670 """ 

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

672 new_flags = self.flags & ~FLAG_STAGEMASK 

673 new_flags |= stage.value << FLAG_STAGESHIFT 

674 return SerializedIndexEntry( 

675 name=name, 

676 ctime=self.ctime, 

677 mtime=self.mtime, 

678 dev=self.dev, 

679 ino=self.ino, 

680 mode=self.mode, 

681 uid=self.uid, 

682 gid=self.gid, 

683 size=self.size, 

684 sha=self.sha, 

685 flags=new_flags, 

686 extended_flags=self.extended_flags, 

687 ) 

688 

689 def stage(self) -> Stage: 

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

691 

692 Returns: 

693 Stage enum value 

694 """ 

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

696 

697 @property 

698 def skip_worktree(self) -> bool: 

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

700 return bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

701 

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

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

704 

705 Also sets FLAG_EXTENDED in self.flags if needed. 

706 """ 

707 if skip: 

708 # Turn on the skip-worktree bit 

709 self.extended_flags |= EXTENDED_FLAG_SKIP_WORKTREE 

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

711 self.flags |= FLAG_EXTENDED 

712 else: 

713 # Turn off the skip-worktree bit 

714 self.extended_flags &= ~EXTENDED_FLAG_SKIP_WORKTREE 

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

716 if self.extended_flags == 0: 

717 self.flags &= ~FLAG_EXTENDED 

718 

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

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

721 

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

723 directory tree in a sparse index. It has: 

724 - Directory mode (0o040000) 

725 - SKIP_WORKTREE flag set 

726 - Path ending with '/' 

727 - SHA pointing to a tree object 

728 

729 Args: 

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

731 

732 Returns: 

733 True if entry is a sparse directory entry 

734 """ 

735 return ( 

736 stat.S_ISDIR(self.mode) 

737 and bool(self.extended_flags & EXTENDED_FLAG_SKIP_WORKTREE) 

738 and name.endswith(b"/") 

739 ) 

740 

741 

742class ConflictedIndexEntry: 

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

744 

745 ancestor: IndexEntry | None 

746 this: IndexEntry | None 

747 other: IndexEntry | None 

748 

749 def __init__( 

750 self, 

751 ancestor: IndexEntry | None = None, 

752 this: IndexEntry | None = None, 

753 other: IndexEntry | None = None, 

754 ) -> None: 

755 """Initialize ConflictedIndexEntry. 

756 

757 Args: 

758 ancestor: The common ancestor entry 

759 this: The current branch entry 

760 other: The other branch entry 

761 """ 

762 self.ancestor = ancestor 

763 self.this = this 

764 self.other = other 

765 

766 

767class UnmergedEntries(Exception): 

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

769 

770 

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

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

773 

774 Args: 

775 path: The path to split. 

776 

777 Returns: 

778 Tuple with directory name and basename 

779 """ 

780 try: 

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

782 except ValueError: 

783 return (b"", path) 

784 else: 

785 return (dirname, basename) 

786 

787 

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

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

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

791 

792 

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

794 """Read a cache time. 

795 

796 Args: 

797 f: File-like object to read from 

798 Returns: 

799 Tuple with seconds and nanoseconds 

800 """ 

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

802 

803 

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

805 """Write a cache time. 

806 

807 Args: 

808 f: File-like object to write to 

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

810 """ 

811 if isinstance(t, int): 

812 t = (t, 0) 

813 elif isinstance(t, float): 

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

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

816 elif not isinstance(t, tuple): 

817 raise TypeError(t) 

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

819 

820 

821class IndexChecksumWriter: 

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

823 

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

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

826 """ 

827 

828 def __init__( 

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

830 ) -> None: 

831 """Initialize the writer. 

832 

833 Args: 

834 f: File-like object to wrap 

835 object_format: Object format determining the checksum algorithm 

836 """ 

837 self.f = f 

838 self.object_format = object_format 

839 self._hash = object_format.new_hash() 

840 

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

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

843 self._hash.update(data) 

844 return self.f.write(data) 

845 

846 def tell(self) -> int: 

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

848 return self.f.tell() 

849 

850 def write_checksum(self) -> bytes: 

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

852 digest = self._hash.digest() 

853 self.f.write(digest) 

854 return digest 

855 

856 def close(self) -> None: 

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

858 self.write_checksum() 

859 self.f.close() 

860 

861 

862class IndexChecksumReader: 

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

864 

865 def __init__( 

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

867 ) -> None: 

868 """Initialize the reader. 

869 

870 Args: 

871 f: File-like object to wrap 

872 object_format: Object format determining the checksum algorithm 

873 """ 

874 self.f = f 

875 self.object_format = object_format 

876 self._hash = object_format.new_hash() 

877 

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

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

880 data = self.f.read(size) 

881 self._hash.update(data) 

882 return data 

883 

884 def tell(self) -> int: 

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

886 return self.f.tell() 

887 

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

889 """Seek the underlying file. 

890 

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

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

893 """ 

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

895 

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

897 """Verify the trailing checksum. 

898 

899 Args: 

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

901 index.skipHash config option is set. 

902 

903 Raises: 

904 ChecksumMismatch: If the stored checksum does not match. 

905 """ 

906 length = self.object_format.oid_length 

907 stored = self.f.read(length) 

908 expected = self._hash.digest() 

909 if stored == expected: 

910 return 

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

912 return 

913 raise ChecksumMismatch( 

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

915 ) 

916 

917 

918def read_cache_entry( 

919 f: _IndexReadable, 

920 version: int, 

921 previous_path: bytes = b"", 

922 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

923) -> SerializedIndexEntry: 

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

925 

926 Args: 

927 f: File-like object to read from 

928 version: Index version 

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

930 object_format: Object format determining the object ID width 

931 """ 

932 beginoffset = f.tell() 

933 ctime = read_cache_time(f) 

934 mtime = read_cache_time(f) 

935 oid_length = object_format.oid_length 

936 ( 

937 dev, 

938 ino, 

939 mode, 

940 uid, 

941 gid, 

942 size, 

943 sha, 

944 flags, 

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

946 if flags & FLAG_EXTENDED: 

947 if version < 3: 

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

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

950 else: 

951 extended_flags = 0 

952 

953 if version >= 4: 

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

955 name, _consumed = _decompress_path_from_stream(f, previous_path) 

956 else: 

957 # Versions < 4: regular name reading 

958 name = f.read(flags & FLAG_NAMEMASK) 

959 

960 # Padding: 

961 if version < 4: 

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

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

964 

965 return SerializedIndexEntry( 

966 name, 

967 ctime, 

968 mtime, 

969 dev, 

970 ino, 

971 mode, 

972 uid, 

973 gid, 

974 size, 

975 sha_to_hex(sha), 

976 flags & ~FLAG_NAMEMASK, 

977 extended_flags, 

978 ) 

979 

980 

981def write_cache_entry( 

982 f: _IndexWritable, 

983 entry: SerializedIndexEntry, 

984 version: int, 

985 previous_path: bytes = b"", 

986 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

987) -> None: 

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

989 

990 Args: 

991 f: File object 

992 entry: IndexEntry to write 

993 version: Index format version 

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

995 object_format: Object format determining the object ID width 

996 """ 

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

998 raise ValueError( 

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

1000 f"a {object_format.name} index" 

1001 ) 

1002 

1003 beginoffset = f.tell() 

1004 write_cache_time(f, entry.ctime) 

1005 write_cache_time(f, entry.mtime) 

1006 

1007 compressed_path = b"" 

1008 if version >= 4: 

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

1010 # This matches how C Git implements index v4 flags 

1011 compressed_path = _compress_path(entry.name, previous_path) 

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

1013 

1014 if entry.extended_flags: 

1015 flags |= FLAG_EXTENDED 

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

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

1018 

1019 f.write( 

1020 struct.pack( 

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

1022 entry.dev & 0xFFFFFFFF, 

1023 entry.ino & 0xFFFFFFFF, 

1024 entry.mode, 

1025 entry.uid, 

1026 entry.gid, 

1027 entry.size, 

1028 hex_to_sha(entry.sha), 

1029 flags, 

1030 ) 

1031 ) 

1032 if flags & FLAG_EXTENDED: 

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

1034 

1035 if version >= 4: 

1036 # Version 4: always write compressed path 

1037 f.write(compressed_path) 

1038 else: 

1039 # Versions < 4: write regular path and padding 

1040 f.write(entry.name) 

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

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

1043 

1044 

1045class UnsupportedIndexFormat(Exception): 

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

1047 

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

1049 """Initialize UnsupportedIndexFormat exception. 

1050 

1051 Args: 

1052 version: The unsupported index format version 

1053 """ 

1054 self.index_format_version = version 

1055 

1056 

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

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

1059 

1060 Returns: 

1061 tuple of (version, num_entries) 

1062 """ 

1063 header = f.read(4) 

1064 if header != b"DIRC": 

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

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

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

1068 raise UnsupportedIndexFormat(version) 

1069 return version, num_entries 

1070 

1071 

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

1073 """Write an index extension. 

1074 

1075 Args: 

1076 f: File-like object to write to 

1077 extension: Extension to write 

1078 """ 

1079 data = extension.to_bytes() 

1080 f.write(extension.signature) 

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

1082 f.write(data) 

1083 

1084 

1085def read_index( 

1086 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

1087) -> Iterator[SerializedIndexEntry]: 

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

1089 version, num_entries = read_index_header(f) 

1090 previous_path = b"" 

1091 for i in range(num_entries): 

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

1093 previous_path = entry.name 

1094 yield entry 

1095 

1096 

1097def read_index_dict_with_version( 

1098 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

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

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

1101 

1102 Returns: 

1103 tuple of (entries_dict, version, extensions) 

1104 """ 

1105 version, num_entries = read_index_header(f) 

1106 

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

1108 previous_path = b"" 

1109 for i in range(num_entries): 

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

1111 previous_path = entry.name 

1112 stage = entry.stage() 

1113 if stage == Stage.NORMAL: 

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

1115 else: 

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

1117 if isinstance(existing, IndexEntry): 

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

1119 match stage: 

1120 case Stage.MERGE_CONFLICT_ANCESTOR: 

1121 existing.ancestor = IndexEntry.from_serialized(entry) 

1122 case Stage.MERGE_CONFLICT_THIS: 

1123 existing.this = IndexEntry.from_serialized(entry) 

1124 case Stage.MERGE_CONFLICT_OTHER: 

1125 existing.other = IndexEntry.from_serialized(entry) 

1126 

1127 # Read extensions 

1128 extensions = [] 

1129 while True: 

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

1131 current_pos = f.tell() 

1132 f.seek(0, 2) # EOF 

1133 eof_pos = f.tell() 

1134 f.seek(current_pos) 

1135 

1136 if current_pos >= eof_pos - object_format.oid_length: 

1137 break 

1138 

1139 # Try to read extension signature 

1140 signature = f.read(4) 

1141 if len(signature) < 4: 

1142 break 

1143 

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

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

1146 # Not an extension, seek back 

1147 f.seek(-4, 1) 

1148 break 

1149 

1150 # Read extension size 

1151 size_data = f.read(4) 

1152 if len(size_data) < 4: 

1153 break 

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

1155 

1156 # Read extension data 

1157 data = f.read(size) 

1158 if len(data) < size: 

1159 break 

1160 

1161 extension = IndexExtension.from_raw(signature, data) 

1162 extensions.append(extension) 

1163 

1164 return ret, version, extensions 

1165 

1166 

1167def read_index_dict( 

1168 f: _IndexReadable, object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT 

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

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

1171 

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

1173 path alone is not unique 

1174 Args: 

1175 f: File object to read fromls. 

1176 object_format: Object format determining the object ID width 

1177 """ 

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

1179 for entry in read_index(f, object_format): 

1180 stage = entry.stage() 

1181 if stage == Stage.NORMAL: 

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

1183 else: 

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

1185 if isinstance(existing, IndexEntry): 

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

1187 match stage: 

1188 case Stage.MERGE_CONFLICT_ANCESTOR: 

1189 existing.ancestor = IndexEntry.from_serialized(entry) 

1190 case Stage.MERGE_CONFLICT_THIS: 

1191 existing.this = IndexEntry.from_serialized(entry) 

1192 case Stage.MERGE_CONFLICT_OTHER: 

1193 existing.other = IndexEntry.from_serialized(entry) 

1194 return ret 

1195 

1196 

1197def write_index( 

1198 f: _IndexWritable, 

1199 entries: Sequence[SerializedIndexEntry], 

1200 version: int | None = None, 

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

1202 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1203) -> None: 

1204 """Write an index file. 

1205 

1206 Args: 

1207 f: File-like object to write to 

1208 version: Version number to write 

1209 entries: Iterable over the entries to write 

1210 extensions: Optional list of extensions to write 

1211 object_format: Object format determining the object ID width 

1212 """ 

1213 if version is None: 

1214 version = DEFAULT_VERSION 

1215 # STEP 1: check if any extended_flags are set 

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

1217 if uses_extended_flags and version < 3: 

1218 # Force or bump the version to 3 

1219 version = 3 

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

1221 if version < 3: 

1222 # Double-check no extended flags appear 

1223 for e in entries: 

1224 if e.extended_flags != 0: 

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

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

1227 f.write(b"DIRC") 

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

1229 previous_path = b"" 

1230 for entry in entries: 

1231 write_cache_entry( 

1232 f, 

1233 entry, 

1234 version=version, 

1235 previous_path=previous_path, 

1236 object_format=object_format, 

1237 ) 

1238 previous_path = entry.name 

1239 

1240 # Write extensions 

1241 if extensions: 

1242 for extension in extensions: 

1243 write_index_extension(f, extension) 

1244 

1245 

1246def write_index_dict( 

1247 f: _IndexWritable, 

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

1249 version: int | None = None, 

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

1251 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1252) -> None: 

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

1254 

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

1256 """ 

1257 entries_list = [] 

1258 for key in sorted(entries): 

1259 value = entries[key] 

1260 if isinstance(value, ConflictedIndexEntry): 

1261 if value.ancestor is not None: 

1262 entries_list.append( 

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

1264 ) 

1265 if value.this is not None: 

1266 entries_list.append( 

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

1268 ) 

1269 if value.other is not None: 

1270 entries_list.append( 

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

1272 ) 

1273 else: 

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

1275 

1276 write_index( 

1277 f, 

1278 entries_list, 

1279 version=version, 

1280 extensions=extensions, 

1281 object_format=object_format, 

1282 ) 

1283 

1284 

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

1286 """Cleanup a mode value. 

1287 

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

1289 

1290 Args: 

1291 mode: Mode to clean up. 

1292 

1293 Returns: 

1294 mode 

1295 """ 

1296 if stat.S_ISLNK(mode): 

1297 return stat.S_IFLNK 

1298 elif stat.S_ISDIR(mode): 

1299 return stat.S_IFDIR 

1300 elif S_ISGITLINK(mode): 

1301 return S_IFGITLINK 

1302 ret = stat.S_IFREG | 0o644 

1303 if mode & 0o100: 

1304 ret |= 0o111 

1305 return ret 

1306 

1307 

1308class Index: 

1309 """A Git Index file.""" 

1310 

1311 _byname: dict[bytes, IndexEntry | ConflictedIndexEntry] 

1312 

1313 def __init__( 

1314 self, 

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

1316 read: bool = True, 

1317 skip_hash: bool = False, 

1318 version: int | None = None, 

1319 *, 

1320 shared_perm: "SharedPerm | None" = None, 

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

1322 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1323 ) -> None: 

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

1325 

1326 Args: 

1327 filename: Path to the index file 

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

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

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

1331 shared_perm: Optional shared repository permission setting 

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

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

1334 checksum. 

1335 path_normalizer: Optional function mapping a filesystem path to a 

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

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

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

1339 existing entry. 

1340 """ 

1341 self._filename = os.fspath(filename) 

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

1343 self._version = version 

1344 self._skip_hash = skip_hash 

1345 self._shared_perm = shared_perm 

1346 self._object_format = object_format 

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

1348 self._path_normalizer = path_normalizer 

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

1350 {} if path_normalizer is not None else None 

1351 ) 

1352 self.clear() 

1353 if read: 

1354 self.read() 

1355 

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

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

1358 

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

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

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

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

1363 

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

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

1366 normalization transparently. Use this when the path is also 

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

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

1369 """ 

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

1371 return name 

1372 assert self._path_normalizer is not None 

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

1374 

1375 @property 

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

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

1378 

1379 Returns: 

1380 Path to the index file 

1381 """ 

1382 return self._filename 

1383 

1384 @property 

1385 def object_format(self) -> ObjectFormat: 

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

1387 return self._object_format 

1388 

1389 def __repr__(self) -> str: 

1390 """Return string representation of Index.""" 

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

1392 

1393 def write(self) -> None: 

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

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

1396 try: 

1397 # Filter out extensions with no meaningful data 

1398 meaningful_extensions = [] 

1399 for ext in self._extensions: 

1400 # Skip extensions that have empty data 

1401 ext_data = ext.to_bytes() 

1402 if ext_data: 

1403 meaningful_extensions.append(ext) 

1404 

1405 if self._skip_hash: 

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

1407 # the trailing checksum 

1408 write_index_dict( 

1409 f, 

1410 self._byname, 

1411 version=self._version, 

1412 extensions=meaningful_extensions, 

1413 object_format=self._object_format, 

1414 ) 

1415 # Write zero bytes instead of the checksum 

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

1417 f.close() 

1418 else: 

1419 checksum_writer = IndexChecksumWriter(f, self._object_format) 

1420 write_index_dict( 

1421 checksum_writer, 

1422 self._byname, 

1423 version=self._version, 

1424 extensions=meaningful_extensions, 

1425 object_format=self._object_format, 

1426 ) 

1427 checksum_writer.close() 

1428 except: 

1429 f.close() 

1430 raise 

1431 

1432 def read(self) -> None: 

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

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

1435 return 

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

1437 try: 

1438 checksum_reader = IndexChecksumReader(f, self._object_format) 

1439 entries, version, extensions = read_index_dict_with_version( 

1440 checksum_reader, self._object_format 

1441 ) 

1442 self._version = version 

1443 self._extensions = extensions 

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

1445 self.set_verbatim(name, value) 

1446 # Extensions have already been read by read_index_dict_with_version 

1447 checksum_reader.check_checksum(allow_empty=True) 

1448 finally: 

1449 f.close() 

1450 

1451 def __len__(self) -> int: 

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

1453 return len(self._byname) 

1454 

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

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

1457 

1458 Returns: Either a IndexEntry or a ConflictedIndexEntry 

1459 Raises KeyError: if the entry does not exist 

1460 """ 

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

1462 

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

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

1465 return iter(self._byname) 

1466 

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

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

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

1470 

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

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

1473 value = self[path] 

1474 if isinstance(value, ConflictedIndexEntry): 

1475 raise UnmergedEntries 

1476 return value.sha 

1477 

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

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

1480 value = self[path] 

1481 if isinstance(value, ConflictedIndexEntry): 

1482 raise UnmergedEntries 

1483 return value.mode 

1484 

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

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

1487 for path in self: 

1488 entry = self[path] 

1489 if isinstance(entry, ConflictedIndexEntry): 

1490 raise UnmergedEntries 

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

1492 

1493 def has_conflicts(self) -> bool: 

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

1495 

1496 Returns: 

1497 True if any entries are conflicted, False otherwise 

1498 """ 

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

1500 if isinstance(value, ConflictedIndexEntry): 

1501 return True 

1502 return False 

1503 

1504 def clear(self) -> None: 

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

1506 self._byname = {} 

1507 if self._normalized is not None: 

1508 self._normalized = {} 

1509 

1510 def __setitem__( 

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

1512 ) -> None: 

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

1514 assert isinstance(name, bytes) 

1515 name = self.canonical_path(name) 

1516 is_new = name not in self._byname 

1517 self._byname[name] = value 

1518 if is_new and self._normalized is not None: 

1519 assert self._path_normalizer is not None 

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

1521 

1522 def set_verbatim( 

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

1524 ) -> None: 

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

1526 

1527 Use this when populating the index from an authoritative source 

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

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

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

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

1532 """ 

1533 assert isinstance(name, bytes) 

1534 self._byname[name] = value 

1535 if self._normalized is not None: 

1536 assert self._path_normalizer is not None 

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

1538 

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

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

1541 name = self.canonical_path(name) 

1542 del self._byname[name] 

1543 if self._normalized is not None: 

1544 assert self._path_normalizer is not None 

1545 normalized_key = self._path_normalizer(name) 

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

1547 del self._normalized[normalized_key] 

1548 

1549 def iteritems( 

1550 self, 

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

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

1553 

1554 Returns: 

1555 Iterator of (path, entry) tuples 

1556 """ 

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

1558 

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

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

1561 

1562 Returns: 

1563 Iterator of (path, entry) tuples 

1564 """ 

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

1566 

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

1568 """Update the index with multiple entries. 

1569 

1570 Args: 

1571 entries: Dictionary mapping paths to index entries 

1572 """ 

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

1574 self[key] = value 

1575 

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

1577 """Generate all paths in the index. 

1578 

1579 Yields: 

1580 Path names as bytes 

1581 """ 

1582 yield from self._byname.keys() 

1583 

1584 def changes_from_tree( 

1585 self, 

1586 object_store: ObjectContainer, 

1587 tree: ObjectID | None, 

1588 want_unchanged: bool = False, 

1589 ) -> Generator[ 

1590 tuple[ 

1591 tuple[bytes | None, bytes | None], 

1592 tuple[int | None, int | None], 

1593 tuple[bytes | None, bytes | None], 

1594 ], 

1595 None, 

1596 None, 

1597 ]: 

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

1599 

1600 Args: 

1601 object_store: Object store to use for retrieving tree contents 

1602 tree: SHA1 of the root tree 

1603 want_unchanged: Whether unchanged files should be reported 

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

1605 newmode), (oldsha, newsha) 

1606 """ 

1607 

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

1609 # Tree paths are authoritative, so match them exactly rather 

1610 # than folding case through canonical_path. 

1611 entry = self._byname[path] 

1612 if isinstance(entry, IndexEntry): 

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

1614 else: 

1615 # Handle ConflictedIndexEntry case 

1616 return b"", 0 

1617 

1618 yield from changes_from_tree( 

1619 self.paths(), 

1620 lookup_entry, 

1621 object_store, 

1622 tree, 

1623 want_unchanged=want_unchanged, 

1624 ) 

1625 

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

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

1628 

1629 Args: 

1630 object_store: Object store to save the tree in 

1631 Returns: 

1632 Root tree SHA 

1633 """ 

1634 return commit_tree( 

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

1636 ) 

1637 

1638 def is_sparse(self) -> bool: 

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

1640 

1641 Returns: 

1642 True if any sparse directory extension is present 

1643 """ 

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

1645 

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

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

1648 

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

1650 expanding any sparse directory entries into their constituent files. 

1651 

1652 Args: 

1653 object_store: Object store to read tree objects from 

1654 

1655 Raises: 

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

1657 """ 

1658 if not self.is_sparse(): 

1659 return 

1660 

1661 # Find all sparse directory entries 

1662 sparse_dirs = [] 

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

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

1665 sparse_dirs.append((path, entry)) 

1666 

1667 # Expand each sparse directory 

1668 for path, entry in sparse_dirs: 

1669 # Remove the sparse directory entry 

1670 del self[path] 

1671 

1672 # Get the tree object 

1673 tree = object_store[entry.sha] 

1674 if not isinstance(tree, Tree): 

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

1676 

1677 # Recursively add all entries from the tree 

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

1679 

1680 # Remove the sparse directory extension 

1681 self._extensions = [ 

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

1683 ] 

1684 

1685 def _expand_tree( 

1686 self, 

1687 prefix: bytes, 

1688 tree: Tree, 

1689 object_store: "BaseObjectStore", 

1690 template_entry: IndexEntry, 

1691 ) -> None: 

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

1693 

1694 Args: 

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

1696 tree: Tree object to expand 

1697 object_store: Object store to read nested trees from 

1698 template_entry: Template entry to copy metadata from 

1699 """ 

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

1701 if prefix: 

1702 full_path = prefix + b"/" + name 

1703 else: 

1704 full_path = name 

1705 

1706 if stat.S_ISDIR(mode): 

1707 # Recursively expand subdirectories 

1708 subtree = object_store[sha] 

1709 if not isinstance(subtree, Tree): 

1710 raise ValueError( 

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

1712 ) 

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

1714 else: 

1715 # Create an index entry for this file 

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

1717 new_entry = IndexEntry( 

1718 ctime=template_entry.ctime, 

1719 mtime=template_entry.mtime, 

1720 dev=template_entry.dev, 

1721 ino=template_entry.ino, 

1722 mode=mode, 

1723 uid=template_entry.uid, 

1724 gid=template_entry.gid, 

1725 size=0, # Size is unknown from tree 

1726 sha=sha, 

1727 flags=0, 

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

1729 ) 

1730 self[full_path] = new_entry 

1731 

1732 def convert_to_sparse( 

1733 self, 

1734 object_store: "BaseObjectStore", 

1735 tree_sha: ObjectID, 

1736 sparse_dirs: Set[bytes], 

1737 ) -> None: 

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

1739 

1740 This collapses directories that are entirely outside the sparse 

1741 checkout cone into single sparse directory entries. 

1742 

1743 Args: 

1744 object_store: Object store to read tree objects 

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

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

1747 

1748 Raises: 

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

1750 """ 

1751 if not sparse_dirs: 

1752 return 

1753 

1754 # Get the base tree 

1755 tree = object_store[tree_sha] 

1756 if not isinstance(tree, Tree): 

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

1758 

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

1760 for dir_path in sparse_dirs: 

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

1762 

1763 # Find the tree SHA for this directory 

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

1765 if subtree_sha is None: 

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

1767 continue 

1768 

1769 # Remove all entries under this directory 

1770 entries_to_remove = [ 

1771 path 

1772 for path in self._byname 

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

1774 ] 

1775 for path in entries_to_remove: 

1776 del self[path] 

1777 

1778 # Create a sparse directory entry 

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

1780 from dulwich.objects import ObjectID 

1781 

1782 sparse_entry = IndexEntry( 

1783 ctime=0, 

1784 mtime=0, 

1785 dev=0, 

1786 ino=0, 

1787 mode=stat.S_IFDIR, 

1788 uid=0, 

1789 gid=0, 

1790 size=0, 

1791 sha=ObjectID(subtree_sha), 

1792 flags=0, 

1793 extended_flags=EXTENDED_FLAG_SKIP_WORKTREE, 

1794 ) 

1795 self[dir_path] = sparse_entry 

1796 

1797 # Add sparse directory extension if not present 

1798 if not self.is_sparse(): 

1799 self._extensions.append(SparseDirExtension()) 

1800 

1801 def _find_subtree_sha( 

1802 self, 

1803 tree: Tree, 

1804 path: bytes, 

1805 object_store: "BaseObjectStore", 

1806 ) -> bytes | None: 

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

1808 

1809 Args: 

1810 tree: Root tree object to search in 

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

1812 object_store: Object store to read nested trees from 

1813 

1814 Returns: 

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

1816 """ 

1817 if not path: 

1818 return tree.get_id(self._object_format) 

1819 

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

1821 current_tree = tree 

1822 

1823 for part in parts: 

1824 # Look for this part in the current tree 

1825 try: 

1826 mode, sha = current_tree[part] 

1827 except KeyError: 

1828 return None 

1829 

1830 if not stat.S_ISDIR(mode): 

1831 # Path component is a file, not a directory 

1832 return None 

1833 

1834 # Load the next tree 

1835 obj = object_store[sha] 

1836 if not isinstance(obj, Tree): 

1837 return None 

1838 current_tree = obj 

1839 

1840 return current_tree.get_id(self._object_format) 

1841 

1842 

1843def commit_tree( 

1844 object_store: ObjectContainer, 

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

1846 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

1847) -> ObjectID: 

1848 """Commit a new tree. 

1849 

1850 Args: 

1851 object_store: Object store to add trees to 

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

1853 object_format: Object format to name the created trees with 

1854 Returns: 

1855 ID of the created tree. 

1856 """ 

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

1858 

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

1860 if path in trees: 

1861 return trees[path] 

1862 dirname, basename = pathsplit(path) 

1863 t = add_tree(dirname) 

1864 assert isinstance(basename, bytes) 

1865 newtree: TreeDict = {} 

1866 t[basename] = newtree 

1867 trees[path] = newtree 

1868 return newtree 

1869 

1870 for path, sha, mode in blobs: 

1871 tree_path, basename = pathsplit(path) 

1872 tree = add_tree(tree_path) 

1873 tree[basename] = (mode, sha) 

1874 

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

1876 tree = Tree() 

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

1878 if isinstance(entry, dict): 

1879 mode = stat.S_IFDIR 

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

1881 else: 

1882 (mode, sha) = entry 

1883 tree.add(basename, mode, sha) 

1884 object_store.add_object(tree) 

1885 return ObjectID(tree.get_id(object_format)) 

1886 

1887 return build_tree(b"") 

1888 

1889 

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

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

1892 

1893 Args: 

1894 object_store: Object store to save the tree in 

1895 index: Index file 

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

1897 Returns: Root tree sha. 

1898 """ 

1899 return commit_tree( 

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

1901 ) 

1902 

1903 

1904def changes_from_tree( 

1905 names: Iterable[bytes], 

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

1907 object_store: ObjectContainer, 

1908 tree: ObjectID | None, 

1909 want_unchanged: bool = False, 

1910) -> Iterable[ 

1911 tuple[ 

1912 tuple[bytes | None, bytes | None], 

1913 tuple[int | None, int | None], 

1914 tuple[bytes | None, bytes | None], 

1915 ] 

1916]: 

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

1918 

1919 Args: 

1920 names: Iterable of names in the working copy 

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

1922 object_store: Object store to use for retrieving tree contents 

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

1924 want_unchanged: Whether unchanged files should be reported 

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

1926 (oldsha, newsha) 

1927 """ 

1928 # TODO(jelmer): Support a include_trees option 

1929 other_names = set(names) 

1930 

1931 if tree is not None: 

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

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

1934 try: 

1935 (other_sha, other_mode) = lookup_entry(name) 

1936 except KeyError: 

1937 # Was removed 

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

1939 else: 

1940 other_names.remove(name) 

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

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

1943 

1944 # Mention added files 

1945 for name in other_names: 

1946 try: 

1947 (other_sha, other_mode) = lookup_entry(name) 

1948 except KeyError: 

1949 pass 

1950 else: 

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

1952 

1953 

1954def index_entry_from_stat( 

1955 stat_val: os.stat_result, 

1956 hex_sha: bytes, 

1957 mode: int | None = None, 

1958 flags: int = 0, 

1959 extended_flags: int = 0, 

1960) -> IndexEntry: 

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

1962 

1963 Args: 

1964 stat_val: POSIX stat_result instance 

1965 hex_sha: Hex sha of the object 

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

1967 flags: Index entry flags 

1968 extended_flags: Index entry extended flags 

1969 """ 

1970 if mode is None: 

1971 mode = cleanup_mode(stat_val.st_mode) 

1972 

1973 from dulwich.objects import ObjectID 

1974 

1975 # Use nanosecond precision when available to avoid precision loss 

1976 # through float representation 

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

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

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

1980 if st_ctime_ns is not None: 

1981 ctime = ( 

1982 st_ctime_ns // 1_000_000_000, 

1983 st_ctime_ns % 1_000_000_000, 

1984 ) 

1985 else: 

1986 ctime = stat_val.st_ctime 

1987 

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

1989 if st_mtime_ns is not None: 

1990 mtime = ( 

1991 st_mtime_ns // 1_000_000_000, 

1992 st_mtime_ns % 1_000_000_000, 

1993 ) 

1994 else: 

1995 mtime = stat_val.st_mtime 

1996 

1997 return IndexEntry( 

1998 ctime=ctime, 

1999 mtime=mtime, 

2000 dev=stat_val.st_dev, 

2001 ino=stat_val.st_ino, 

2002 mode=mode, 

2003 uid=stat_val.st_uid, 

2004 gid=stat_val.st_gid, 

2005 size=stat_val.st_size, 

2006 sha=ObjectID(hex_sha), 

2007 flags=flags, 

2008 extended_flags=extended_flags, 

2009 ) 

2010 

2011 

2012def unchanged_entry_flags( 

2013 index: "Index | dict[bytes, IndexEntry | ConflictedIndexEntry]", 

2014 path: bytes, 

2015 hex_sha: bytes, 

2016 mode: int, 

2017) -> tuple[int, int]: 

2018 """Return the flags to carry over when replacing the index entry for path. 

2019 

2020 An entry keeps its flags (such as assume-unchanged or skip-worktree) 

2021 as long as its sha and mode stay the same. 

2022 

2023 Returns: 

2024 Tuple of (flags, extended_flags); zeros if there is no matching entry 

2025 """ 

2026 try: 

2027 entry = index[path] 

2028 except KeyError: 

2029 return 0, 0 

2030 if isinstance(entry, IndexEntry) and entry.sha == hex_sha and entry.mode == mode: 

2031 return entry.flags, entry.extended_flags 

2032 return 0, 0 

2033 

2034 

2035def index_entry_from_tree_entry( 

2036 mode: int, 

2037 hex_sha: bytes, 

2038 size: int = 0, 

2039 flags: int = 0, 

2040 extended_flags: int = 0, 

2041) -> IndexEntry: 

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

2043 

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

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

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

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

2048 

2049 Args: 

2050 mode: File mode from the tree entry 

2051 hex_sha: Hex sha of the object 

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

2053 flags: Index entry flags 

2054 extended_flags: Index entry extended flags 

2055 """ 

2056 from dulwich.objects import ObjectID 

2057 

2058 return IndexEntry( 

2059 ctime=0, 

2060 mtime=0, 

2061 dev=0, 

2062 ino=0, 

2063 mode=mode, 

2064 uid=0, 

2065 gid=0, 

2066 size=size, 

2067 sha=ObjectID(hex_sha), 

2068 flags=flags, 

2069 extended_flags=extended_flags, 

2070 ) 

2071 

2072 

2073if sys.platform == "win32": 

2074 # On Windows, creating symlinks either requires administrator privileges 

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

2076 # create symlinks 

2077 

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

2079 

2080 class WindowsSymlinkPermissionError(PermissionError): 

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

2082 

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

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

2085 """ 

2086 

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

2088 """Initialize WindowsSymlinkPermissionError.""" 

2089 super().__init__( 

2090 errno, 

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

2092 filename, 

2093 ) 

2094 

2095 def symlink( 

2096 src: str | bytes, 

2097 dst: str | bytes, 

2098 target_is_directory: bool = False, 

2099 *, 

2100 dir_fd: int | None = None, 

2101 ) -> None: 

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

2103 

2104 Args: 

2105 src: Source path for the symlink 

2106 dst: Destination path where symlink will be created 

2107 target_is_directory: Whether the target is a directory 

2108 dir_fd: Optional directory file descriptor 

2109 

2110 Raises: 

2111 WindowsSymlinkPermissionError: If symlink creation fails due to permissions 

2112 """ 

2113 try: 

2114 return os.symlink( 

2115 src, dst, target_is_directory=target_is_directory, dir_fd=dir_fd 

2116 ) 

2117 except PermissionError as e: 

2118 raise WindowsSymlinkPermissionError( 

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

2120 ) from e 

2121else: 

2122 symlink = os.symlink 

2123 

2124 

2125def get_symlink_fn( 

2126 config: "Config", 

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

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

2129 

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

2131 files containing the link target, like git does. 

2132 

2133 Args: 

2134 config: Repository configuration 

2135 

2136 Returns: 

2137 Function taking the link target and the path to create 

2138 """ 

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

2140 

2141 def create_symlink( 

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

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

2144 ) -> None: 

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

2146 

2147 return create_symlink 

2148 

2149 def write_link_as_file( 

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

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

2152 ) -> None: 

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

2154 with open(target, mode) as f: 

2155 f.write(source) 

2156 

2157 return write_link_as_file 

2158 

2159 

2160def build_file_from_blob( 

2161 blob: Blob, 

2162 mode: int, 

2163 target_path: bytes, 

2164 *, 

2165 honor_filemode: bool = True, 

2166 tree_encoding: str = "utf-8", 

2167 symlink_fn: Callable[ 

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

2169 ] 

2170 | None = None, 

2171) -> os.stat_result: 

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

2173 

2174 Args: 

2175 blob: The git object 

2176 mode: File mode 

2177 target_path: Path to write to 

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

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

2180 tree_encoding: Encoding to use for tree contents 

2181 symlink_fn: Function to use for creating symlinks 

2182 Returns: stat object for the file 

2183 

2184 Raises: 

2185 InvalidPathError: If asked to write ``.gitmodules`` as a symlink 

2186 """ 

2187 if stat.S_ISLNK(mode) and os.path.basename(target_path).lower() == b".gitmodules": 

2188 # .gitmodules is read from the work tree, so a symlink here would 

2189 # source submodule configuration from a file outside the tree. 

2190 raise InvalidPathError(target_path) 

2191 try: 

2192 oldstat = os.lstat(target_path) 

2193 except FileNotFoundError: 

2194 oldstat = None 

2195 contents = blob.as_raw_string() 

2196 if stat.S_ISLNK(mode): 

2197 if oldstat: 

2198 _remove_file_with_readonly_handling(target_path) 

2199 if sys.platform == "win32": 

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

2201 contents_str = contents.decode(tree_encoding) 

2202 target_path_str = target_path.decode(tree_encoding) 

2203 (symlink_fn or symlink)(contents_str, target_path_str) 

2204 else: 

2205 (symlink_fn or symlink)(contents, target_path) 

2206 else: 

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

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

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

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

2211 # regular file, like git does on checkout. 

2212 _remove_file_with_readonly_handling(target_path) 

2213 oldstat = None 

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

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

2216 unchanged = f.read() == contents 

2217 if unchanged: 

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

2219 mode & stat.S_IXUSR 

2220 ): 

2221 return oldstat 

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

2223 return os.lstat(target_path) 

2224 

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

2226 # Write out file 

2227 f.write(contents) 

2228 

2229 if honor_filemode: 

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

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

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

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

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

2235 

2236 return os.lstat(target_path) 

2237 

2238 

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

2240 

2241 

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

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

2244 return element.lower() 

2245 

2246 

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

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

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

2250 

2251 

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

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

2254 import unicodedata 

2255 

2256 # Decode to Unicode (let UnicodeDecodeError bubble up) 

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

2258 

2259 # Remove HFS+ ignorable characters 

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

2261 # Normalize to NFD 

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

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

2264 

2265 

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

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

2268 

2269 Args: 

2270 config: Repository configuration object 

2271 

2272 Returns: 

2273 Function that normalizes path elements for the configured filesystem 

2274 """ 

2275 import os 

2276 import sys 

2277 

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

2279 return _normalize_path_element_ntfs 

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

2281 return _normalize_path_element_hfs 

2282 else: 

2283 return _normalize_path_element_default 

2284 

2285 

2286def make_path_normalizer( 

2287 config: "Config", 

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

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

2290 

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

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

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

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

2295 comparison entirely. 

2296 """ 

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

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

2299 if not ignorecase and not precompose: 

2300 return None 

2301 

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

2303 if precompose: 

2304 import unicodedata 

2305 

2306 try: 

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

2308 "utf-8" 

2309 ) 

2310 except UnicodeDecodeError: 

2311 pass 

2312 if ignorecase: 

2313 path = path.lower() 

2314 return path 

2315 

2316 return normalize 

2317 

2318 

2319class InvalidPathError(Exception): 

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

2321 

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

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

2324 self.path = path 

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

2326 

2327 

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

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

2330 

2331 Args: 

2332 element: Path element to validate 

2333 

2334 Returns: 

2335 True if path element is valid, False otherwise 

2336 """ 

2337 return _normalize_path_element_default(element) not in INVALID_DOTNAMES 

2338 

2339 

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

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

2342 

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

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

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

2346 """ 

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

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

2349 return False 

2350 i = 4 

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

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

2353 return False 

2354 i = 5 

2355 else: 

2356 return False 

2357 

2358 while i < len(name): 

2359 c = name[i : i + 1] 

2360 if c == b":": 

2361 return True 

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

2363 return False 

2364 i += 1 

2365 return True 

2366 

2367 

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

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

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

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

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

2373RESERVED_WINDOWS_DEVICE_NAMES = frozenset( 

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

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

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

2377) 

2378 

2379 

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

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

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

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

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

2385 return stem in RESERVED_WINDOWS_DEVICE_NAMES 

2386 

2387 

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

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

2390 

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

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

2393 """ 

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

2395 return False 

2396 return name[0] < 0x80 

2397 

2398 

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

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

2401 

2402 Args: 

2403 element: Path element to validate 

2404 

2405 Returns: 

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

2407 """ 

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

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

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

2411 return False 

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

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

2414 if _is_ntfs_dotgit(segment): 

2415 return False 

2416 normalized = _normalize_path_element_ntfs(element) 

2417 if normalized in INVALID_DOTNAMES: 

2418 return False 

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

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

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

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

2423 return False 

2424 return True 

2425 

2426 

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

2428HFS_IGNORABLE_CHARS = { 

2429 0x200C, # ZERO WIDTH NON-JOINER 

2430 0x200D, # ZERO WIDTH JOINER 

2431 0x200E, # LEFT-TO-RIGHT MARK 

2432 0x200F, # RIGHT-TO-LEFT MARK 

2433 0x202A, # LEFT-TO-RIGHT EMBEDDING 

2434 0x202B, # RIGHT-TO-LEFT EMBEDDING 

2435 0x202C, # POP DIRECTIONAL FORMATTING 

2436 0x202D, # LEFT-TO-RIGHT OVERRIDE 

2437 0x202E, # RIGHT-TO-LEFT OVERRIDE 

2438 0x206A, # INHIBIT SYMMETRIC SWAPPING 

2439 0x206B, # ACTIVATE SYMMETRIC SWAPPING 

2440 0x206C, # INHIBIT ARABIC FORM SHAPING 

2441 0x206D, # ACTIVATE ARABIC FORM SHAPING 

2442 0x206E, # NATIONAL DIGIT SHAPES 

2443 0x206F, # NOMINAL DIGIT SHAPES 

2444 0xFEFF, # ZERO WIDTH NO-BREAK SPACE 

2445} 

2446 

2447 

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

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

2450 

2451 Equivalent to Git's is_hfs_dotgit and related checks. 

2452 Uses NFD normalization and ignores HFS+ ignorable characters. 

2453 """ 

2454 try: 

2455 normalized = _normalize_path_element_hfs(element) 

2456 except UnicodeDecodeError: 

2457 # Malformed UTF-8 - be conservative and reject 

2458 return False 

2459 

2460 # Check against invalid names 

2461 if normalized in INVALID_DOTNAMES: 

2462 return False 

2463 

2464 # Also check for 8.3 short name 

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

2466 return False 

2467 

2468 return True 

2469 

2470 

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

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

2473 

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

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

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

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

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

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

2480 only refuses ``.git``, ``.`` and ``..``. 

2481 

2482 Args: 

2483 config: Repository configuration object 

2484 

2485 Returns: 

2486 Function that validates a single path element for the configured 

2487 filesystem protections. 

2488 """ 

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

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

2491 validators.append(validate_path_element_ntfs) 

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

2493 validators.append(validate_path_element_hfs) 

2494 if not validators: 

2495 return validate_path_element_default 

2496 if len(validators) == 1: 

2497 return validators[0] 

2498 

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

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

2501 

2502 return validate_all 

2503 

2504 

2505def validate_path( 

2506 path: bytes, 

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

2508) -> bool: 

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

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

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

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

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

2514 return False 

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

2516 for p in parts: 

2517 if not element_validator(p): 

2518 return False 

2519 else: 

2520 return True 

2521 

2522 

2523def verify_leading_dirs( 

2524 tree_path: bytes, 

2525 safe_prefix: list[bytes], 

2526 repo_path: bytes, 

2527) -> None: 

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

2529 

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

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

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

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

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

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

2536 

2537 Args: 

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

2539 safe_prefix: Mutable cache of directory components already verified 

2540 under ``repo_path``. Updated in place. 

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

2542 

2543 Raises: 

2544 InvalidPathError: If any leading component is a symlink. 

2545 """ 

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

2547 if slash <= 0: 

2548 return 

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

2550 

2551 common = 0 

2552 while ( 

2553 common < len(safe_prefix) 

2554 and common < len(components) 

2555 and safe_prefix[common] == components[common] 

2556 ): 

2557 common += 1 

2558 del safe_prefix[common:] 

2559 

2560 current = repo_path 

2561 for part in components[:common]: 

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

2563 for part in components[common:]: 

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

2565 try: 

2566 st = os.lstat(current) 

2567 except FileNotFoundError: 

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

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

2570 break 

2571 if stat.S_ISLNK(st.st_mode): 

2572 raise InvalidPathError(tree_path) 

2573 safe_prefix.append(part) 

2574 

2575 

2576def verify_tree_path( 

2577 tree_path: bytes, 

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

2579 safe_prefix: list[bytes], 

2580 root_path: bytes, 

2581) -> None: 

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

2583 

2584 Combines ``validate_path`` and ``verify_leading_dirs``. 

2585 

2586 Args: 

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

2588 validate_path_element: Function to validate a single path element. 

2589 safe_prefix: Mutable cache shared across calls, see 

2590 ``verify_leading_dirs``. 

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

2592 

2593 Raises: 

2594 InvalidPathError: If the path has a component rejected by 

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

2596 """ 

2597 if not validate_path(tree_path, validate_path_element): 

2598 raise InvalidPathError(tree_path) 

2599 verify_leading_dirs(tree_path, safe_prefix, root_path) 

2600 

2601 

2602def build_index_from_tree( 

2603 root_path: str | bytes, 

2604 index_path: str | bytes, 

2605 object_store: ObjectContainer, 

2606 tree_id: ObjectID, 

2607 honor_filemode: bool = True, 

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

2609 symlink_fn: Callable[ 

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

2611 ] 

2612 | None = None, 

2613 blob_normalizer: "FilterBlobNormalizer | None" = None, 

2614 tree_encoding: str = "utf-8", 

2615 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

2616) -> None: 

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

2618 

2619 Args: 

2620 tree_id: Tree to materialize 

2621 root_path: Target dir for materialized index files 

2622 index_path: Target path for generated index 

2623 object_store: Non-empty object store holding tree contents 

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

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

2626 validate_path_element: Function to validate path elements to check 

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

2628 symlink_fn: Function to use for creating symlinks 

2629 blob_normalizer: An optional BlobNormalizer to use for converting line 

2630 endings when writing blobs to the working directory. 

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

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

2633 

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

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

2636 """ 

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

2638 if not isinstance(root_path, bytes): 

2639 root_path = os.fsencode(root_path) 

2640 

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

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

2643 safe_prefix: list[bytes] = [] 

2644 

2645 for entry in iter_tree_contents(object_store, tree_id): 

2646 assert ( 

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

2648 ) 

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

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

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

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

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

2654 

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

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

2657 

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

2659 if S_ISGITLINK(entry.mode): 

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

2661 os.mkdir(full_path) 

2662 st = os.lstat(full_path) 

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

2664 else: 

2665 obj = object_store[entry.sha] 

2666 assert isinstance(obj, Blob) 

2667 # Apply blob normalization for checkout if normalizer is provided 

2668 if blob_normalizer is not None: 

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

2670 st = build_file_from_blob( 

2671 obj, 

2672 entry.mode, 

2673 full_path, 

2674 honor_filemode=honor_filemode, 

2675 tree_encoding=tree_encoding, 

2676 symlink_fn=symlink_fn, 

2677 ) 

2678 

2679 # Add file to index 

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

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

2682 # because on windows that will convert the times to 

2683 # longs, which causes errors further along 

2684 st_tuple = ( 

2685 entry.mode, 

2686 st.st_ino, 

2687 st.st_dev, 

2688 st.st_nlink, 

2689 st.st_uid, 

2690 st.st_gid, 

2691 st.st_size, 

2692 st.st_atime, 

2693 st.st_mtime, 

2694 st.st_ctime, 

2695 ) 

2696 st = st.__class__(st_tuple) 

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

2698 # when reading from the filesystem 

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

2700 

2701 index.write() 

2702 

2703 

2704def blob_from_path_and_mode( 

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

2706) -> Blob: 

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

2708 

2709 Args: 

2710 fs_path: Full file system path to file 

2711 mode: File mode 

2712 tree_encoding: Encoding to use for tree contents 

2713 Returns: A `Blob` object 

2714 """ 

2715 assert isinstance(fs_path, bytes) 

2716 blob = Blob() 

2717 if stat.S_ISLNK(mode): 

2718 if sys.platform == "win32": 

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

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

2721 else: 

2722 blob.data = os.readlink(fs_path) 

2723 else: 

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

2725 blob.data = f.read() 

2726 return blob 

2727 

2728 

2729def blob_from_path_and_stat( 

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

2731) -> Blob: 

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

2733 

2734 Args: 

2735 fs_path: Full file system path to file 

2736 st: A stat object 

2737 tree_encoding: Encoding to use for tree contents 

2738 Returns: A `Blob` object 

2739 """ 

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

2741 

2742 

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

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

2745 

2746 Args: 

2747 path: path to the submodule 

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

2749 """ 

2750 from .errors import NotGitRepository 

2751 from .repo import Repo 

2752 

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

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

2755 if not isinstance(path, str): 

2756 path = os.fsdecode(path) 

2757 try: 

2758 repo = Repo(path) 

2759 except NotGitRepository: 

2760 return None 

2761 try: 

2762 return repo.head() 

2763 except KeyError: 

2764 return None 

2765 

2766 

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

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

2769 

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

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

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

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

2774 directory. 

2775 

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

2777 otherwise or if the path is not a directory. 

2778 """ 

2779 # This is actually a directory 

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

2781 # Submodule 

2782 head = read_submodule_head(tree_path) 

2783 if entry.sha != head: 

2784 return True 

2785 else: 

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

2787 return True 

2788 

2789 return False 

2790 

2791 

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

2793 

2794 

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

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

2797 parent_dir = os.path.dirname(full_path) 

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

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

2800 current = parent_dir 

2801 parents_to_check: list[bytes] = [] 

2802 

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

2804 parents_to_check.insert(0, current) 

2805 new_parent = os.path.dirname(current) 

2806 if new_parent == current: 

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

2808 break 

2809 current = new_parent 

2810 

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

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

2813 raise OSError( 

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

2815 ) 

2816 

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

2818 for parent_path in parents_to_check: 

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

2820 raise OSError( 

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

2822 ) 

2823 

2824 os.makedirs(parent_dir) 

2825 

2826 

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

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

2829 

2830 Args: 

2831 path: Path to the file to remove 

2832 """ 

2833 try: 

2834 os.unlink(path) 

2835 except PermissionError: 

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

2837 if sys.platform == "win32": 

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

2839 os.unlink(path) 

2840 else: 

2841 raise 

2842 

2843 

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

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

2846 parent = os.path.dirname(path) 

2847 while parent and parent != stop_at: 

2848 try: 

2849 os.rmdir(parent) 

2850 parent = os.path.dirname(parent) 

2851 except FileNotFoundError: 

2852 # Directory doesn't exist - stop trying 

2853 break 

2854 except OSError as e: 

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

2856 # Directory not empty - stop trying 

2857 break 

2858 raise 

2859 

2860 

2861def _check_symlink_matches( 

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

2863) -> bool: 

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

2865 

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

2867 """ 

2868 try: 

2869 current_target = os.readlink(full_path) 

2870 blob_obj = repo_object_store[entry_sha] 

2871 expected_target = blob_obj.as_raw_string() 

2872 if isinstance(current_target, str): 

2873 current_target = current_target.encode() 

2874 return current_target == expected_target 

2875 except FileNotFoundError: 

2876 # Symlink doesn't exist 

2877 return False 

2878 except OSError as e: 

2879 if e.errno == errno.EINVAL: 

2880 # Not a symlink 

2881 return False 

2882 raise 

2883 

2884 

2885def _check_file_matches( 

2886 repo_object_store: "BaseObjectStore", 

2887 full_path: bytes, 

2888 entry_sha: ObjectID, 

2889 entry_mode: int, 

2890 current_stat: os.stat_result, 

2891 honor_filemode: bool, 

2892 blob_normalizer: "FilterBlobNormalizer | None" = None, 

2893 tree_path: bytes | None = None, 

2894) -> bool: 

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

2896 

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

2898 """ 

2899 # Check mode first (if honor_filemode is True) 

2900 if honor_filemode: 

2901 current_mode = stat.S_IMODE(current_stat.st_mode) 

2902 expected_mode = stat.S_IMODE(entry_mode) 

2903 

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

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

2906 if stat.S_ISREG(current_stat.st_mode): 

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

2908 current_mode_normalized = ( 

2909 current_mode & 0o755 

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

2911 expected_mode_normalized = expected_mode & 0o755 

2912 

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

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

2915 expected_mode_normalized = 0o644 # Default for regular files 

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

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

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

2919 current_mode_normalized = 0o755 

2920 else: 

2921 current_mode_normalized = 0o644 

2922 

2923 if current_mode_normalized != expected_mode_normalized: 

2924 return False 

2925 else: 

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

2927 if current_mode != expected_mode: 

2928 return False 

2929 

2930 blob_obj = repo_object_store[entry_sha] 

2931 normalize = blob_normalizer is not None and tree_path is not None 

2932 # Filters can change the size, so only take the shortcut without them. 

2933 if not normalize and current_stat.st_size != blob_obj.raw_length(): 

2934 return False 

2935 

2936 try: 

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

2938 current_content = f.read() 

2939 except (FileNotFoundError, PermissionError, IsADirectoryError): 

2940 return False 

2941 

2942 # Like git, clean the working tree file and compare it with the blob. 

2943 if normalize: 

2944 assert blob_normalizer is not None and tree_path is not None 

2945 current_content = blob_normalizer.checkin_normalize( 

2946 Blob.from_string(current_content), tree_path 

2947 ).as_raw_string() 

2948 return current_content == blob_obj.as_raw_string() 

2949 

2950 

2951def _transition_to_submodule( 

2952 repo: "Repo", 

2953 path: bytes, 

2954 full_path: bytes, 

2955 current_stat: os.stat_result | None, 

2956 entry: IndexEntry | TreeEntry, 

2957 index: Index, 

2958) -> None: 

2959 """Transition any type to submodule.""" 

2960 from .submodule import ensure_submodule_placeholder 

2961 

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

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

2964 ensure_submodule_placeholder(repo, path) 

2965 else: 

2966 # Remove whatever is there and create submodule 

2967 if current_stat is not None: 

2968 _remove_file_with_readonly_handling(full_path) 

2969 ensure_submodule_placeholder(repo, path) 

2970 

2971 st = os.lstat(full_path) 

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

2973 flags, extended_flags = unchanged_entry_flags(index, path, entry.sha, entry.mode) 

2974 index[path] = index_entry_from_stat( 

2975 st, entry.sha, flags=flags, extended_flags=extended_flags 

2976 ) 

2977 

2978 

2979def _transition_to_file( 

2980 object_store: "BaseObjectStore", 

2981 path: bytes, 

2982 full_path: bytes, 

2983 current_stat: os.stat_result | None, 

2984 entry: IndexEntry | TreeEntry, 

2985 index: Index, 

2986 honor_filemode: bool, 

2987 symlink_fn: Callable[ 

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

2989 ] 

2990 | None, 

2991 blob_normalizer: "FilterBlobNormalizer | None", 

2992 tree_encoding: str = "utf-8", 

2993) -> None: 

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

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

2996 # Without core.filemode the working tree's executable bit is not 

2997 # trusted, so record the tree's mode in the index like git does. 

2998 index_mode = None if honor_filemode else entry.mode 

2999 flags, extended_flags = unchanged_entry_flags(index, path, entry.sha, entry.mode) 

3000 # Check if we need to update 

3001 if ( 

3002 current_stat is not None 

3003 and stat.S_ISREG(current_stat.st_mode) 

3004 and not stat.S_ISLNK(entry.mode) 

3005 ): 

3006 # File to file - check if update needed 

3007 file_matches = _check_file_matches( 

3008 object_store, 

3009 full_path, 

3010 entry.sha, 

3011 entry.mode, 

3012 current_stat, 

3013 honor_filemode, 

3014 blob_normalizer, 

3015 path, 

3016 ) 

3017 needs_update = not file_matches 

3018 elif ( 

3019 current_stat is not None 

3020 and stat.S_ISLNK(current_stat.st_mode) 

3021 and stat.S_ISLNK(entry.mode) 

3022 ): 

3023 # Symlink to symlink - check if update needed 

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

3025 needs_update = not symlink_matches 

3026 else: 

3027 needs_update = True 

3028 

3029 if not needs_update: 

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

3031 assert current_stat is not None 

3032 index[path] = index_entry_from_stat( 

3033 current_stat, 

3034 entry.sha, 

3035 mode=index_mode, 

3036 flags=flags, 

3037 extended_flags=extended_flags, 

3038 ) 

3039 return 

3040 

3041 # Remove existing entry if needed 

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

3043 # Remove directory 

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

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

3046 

3047 if git_file_name in dir_contents: 

3048 if dir_contents != {git_file_name}: 

3049 raise IsADirectoryError( 

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

3051 ) 

3052 shutil.rmtree(full_path) 

3053 else: 

3054 try: 

3055 os.rmdir(full_path) 

3056 except OSError as e: 

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

3058 raise IsADirectoryError( 

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

3060 ) 

3061 raise 

3062 elif current_stat is not None: 

3063 _remove_file_with_readonly_handling(full_path) 

3064 

3065 # Ensure parent directory exists 

3066 _ensure_parent_dir_exists(full_path) 

3067 

3068 # Write the file 

3069 blob_obj = object_store[entry.sha] 

3070 assert isinstance(blob_obj, Blob) 

3071 if blob_normalizer: 

3072 blob_obj = blob_normalizer.checkout_normalize(blob_obj, path) 

3073 st = build_file_from_blob( 

3074 blob_obj, 

3075 entry.mode, 

3076 full_path, 

3077 honor_filemode=honor_filemode, 

3078 tree_encoding=tree_encoding, 

3079 symlink_fn=symlink_fn, 

3080 ) 

3081 index[path] = index_entry_from_stat( 

3082 st, entry.sha, mode=index_mode, flags=flags, extended_flags=extended_flags 

3083 ) 

3084 

3085 

3086def _transition_to_absent( 

3087 repo: "Repo", 

3088 path: bytes, 

3089 full_path: bytes, 

3090 current_stat: os.stat_result | None, 

3091 index: Index, 

3092) -> None: 

3093 """Remove any type of entry.""" 

3094 # The index entry has to go even if there is nothing on disk, e.g. for 

3095 # sparse-excluded paths or files deleted without "git rm". 

3096 try: 

3097 del index[path] 

3098 except KeyError: 

3099 pass 

3100 

3101 if current_stat is None: 

3102 return 

3103 

3104 if stat.S_ISDIR(current_stat.st_mode): 

3105 # Check if it's a submodule directory 

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

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

3108 

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

3110 shutil.rmtree(full_path) 

3111 else: 

3112 try: 

3113 os.rmdir(full_path) 

3114 except OSError as e: 

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

3116 raise 

3117 else: 

3118 _remove_file_with_readonly_handling(full_path) 

3119 

3120 # Try to remove empty parent directories 

3121 _remove_empty_parents( 

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

3123 ) 

3124 

3125 

3126def detect_case_only_renames( 

3127 changes: Sequence["TreeChange"], 

3128 config: "Config", 

3129) -> list["TreeChange"]: 

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

3131 

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

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

3134 CHANGE_RENAME operations. It uses filesystem-appropriate path normalization 

3135 based on the repository configuration. 

3136 

3137 Args: 

3138 changes: List of TreeChange objects representing file changes 

3139 config: Repository configuration object 

3140 

3141 Returns: 

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

3143 """ 

3144 from .diff_tree import ( 

3145 CHANGE_ADD, 

3146 CHANGE_COPY, 

3147 CHANGE_DELETE, 

3148 CHANGE_MODIFY, 

3149 CHANGE_RENAME, 

3150 TreeChange, 

3151 ) 

3152 

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

3154 old_paths_normalized = {} 

3155 new_paths_normalized = {} 

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

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

3158 

3159 # Get the appropriate normalizer based on config 

3160 normalize_func = get_path_element_normalizer(config) 

3161 

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

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

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

3165 

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

3167 for change in changes: 

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

3169 assert change.old.path is not None 

3170 try: 

3171 normalized = normalize_path(change.old.path) 

3172 except UnicodeDecodeError: 

3173 logger.warning( 

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

3175 change.old.path, 

3176 ) 

3177 else: 

3178 old_paths_normalized[normalized] = change.old.path 

3179 old_changes[change.old.path] = change 

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

3181 assert change.old.path is not None 

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

3183 try: 

3184 normalized = normalize_path(change.old.path) 

3185 except UnicodeDecodeError: 

3186 logger.warning( 

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

3188 change.old.path, 

3189 ) 

3190 else: 

3191 old_paths_normalized[normalized] = change.old.path 

3192 old_changes[change.old.path] = change 

3193 

3194 if ( 

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

3196 and change.new 

3197 ): 

3198 assert change.new.path is not None 

3199 try: 

3200 normalized = normalize_path(change.new.path) 

3201 except UnicodeDecodeError: 

3202 logger.warning( 

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

3204 change.new.path, 

3205 ) 

3206 else: 

3207 new_paths_normalized[normalized] = change.new.path 

3208 new_changes[change.new.path] = change 

3209 

3210 # Find case-only renames and transform changes 

3211 case_only_renames = set() 

3212 new_rename_changes = [] 

3213 

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

3215 if norm_path in new_paths_normalized: 

3216 new_path = new_paths_normalized[norm_path] 

3217 if old_path != new_path: 

3218 # Found a case-only rename 

3219 old_change = old_changes[old_path] 

3220 new_change = new_changes[new_path] 

3221 

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

3223 if new_change.type == CHANGE_ADD: 

3224 # Simple case: DELETE + ADD becomes RENAME 

3225 rename_change = TreeChange( 

3226 CHANGE_RENAME, old_change.old, new_change.new 

3227 ) 

3228 else: 

3229 # Complex case: DELETE + MODIFY becomes RENAME 

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

3231 rename_change = TreeChange( 

3232 CHANGE_RENAME, old_change.old, new_change.new 

3233 ) 

3234 

3235 new_rename_changes.append(rename_change) 

3236 

3237 # Mark the old changes for removal 

3238 case_only_renames.add(old_change) 

3239 case_only_renames.add(new_change) 

3240 

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

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

3243 result.extend(new_rename_changes) 

3244 return result 

3245 

3246 

3247def _sparse_checkout_matcher( 

3248 repo: "Repo", config: "Config", tree_encoding: str 

3249) -> Callable[[bytes], bool] | None: 

3250 """Return a predicate for paths inside the sparse checkout, if enabled.""" 

3251 if not config.get_boolean((b"core",), b"sparseCheckout", False): 

3252 return None 

3253 worktree = repo.get_worktree() 

3254 if not os.path.exists(worktree._sparse_checkout_file_path()): 

3255 # Without a patterns file, sparse checkout is not in effect. 

3256 return None 

3257 from .sparse_patterns import parse_sparse_patterns, sparse_path_matcher 

3258 

3259 lines = worktree.get_sparse_checkout_patterns() 

3260 if not parse_sparse_patterns(lines): 

3261 # An existing file without patterns excludes everything. 

3262 return lambda path: False 

3263 matcher = sparse_path_matcher( 

3264 lines, 

3265 worktree.infer_cone_mode(), 

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

3267 ) 

3268 return lambda path: matcher(path.decode(tree_encoding)) 

3269 

3270 

3271def _skip_worktree_entry(sha: ObjectID, mode: int) -> IndexEntry: 

3272 entry = IndexEntry( 

3273 ctime=0, 

3274 mtime=0, 

3275 dev=0, 

3276 ino=0, 

3277 mode=mode, 

3278 uid=0, 

3279 gid=0, 

3280 size=0, 

3281 sha=sha, 

3282 ) 

3283 entry.set_skip_worktree(True) 

3284 return entry 

3285 

3286 

3287def update_working_tree( 

3288 repo: "Repo", 

3289 old_tree_id: bytes | None, 

3290 new_tree_id: bytes, 

3291 change_iterator: Iterator["TreeChange"], 

3292 honor_filemode: bool = True, 

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

3294 symlink_fn: Callable[ 

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

3296 ] 

3297 | None = None, 

3298 force_remove_untracked: bool = False, 

3299 blob_normalizer: "FilterBlobNormalizer | None" = None, 

3300 tree_encoding: str = "utf-8", 

3301 allow_overwrite_modified: bool = False, 

3302 *, 

3303 config: "Config | None" = None, 

3304) -> None: 

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

3306 

3307 This function handles: 

3308 - Adding new files 

3309 - Updating modified files 

3310 - Removing deleted files 

3311 - Cleaning up empty directories 

3312 

3313 If ``core.sparseCheckout`` is enabled, paths outside the sparse-checkout 

3314 patterns are recorded in the index with the skip-worktree bit set and 

3315 kept out of the working tree. 

3316 

3317 Args: 

3318 repo: Repository object 

3319 old_tree_id: SHA of the tree before the update 

3320 new_tree_id: SHA of the tree to update to 

3321 change_iterator: Iterator of TreeChange objects to apply 

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

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

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

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

3326 symlink_fn: Function to use for creating symlinks 

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

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

3329 blob_normalizer: An optional BlobNormalizer to use for converting line 

3330 endings when writing blobs to the working directory. 

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

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

3333 overwrite files that have been modified compared to old_tree_id 

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

3335 ``repo.get_config_stack()``. 

3336 """ 

3337 from .diff_tree import ( 

3338 CHANGE_ADD, 

3339 CHANGE_COPY, 

3340 CHANGE_DELETE, 

3341 CHANGE_MODIFY, 

3342 CHANGE_RENAME, 

3343 CHANGE_UNCHANGED, 

3344 ) 

3345 

3346 if force_remove_untracked: 

3347 import warnings 

3348 

3349 warnings.warn( 

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

3351 DeprecationWarning, 

3352 ) 

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

3354 if config is None: 

3355 config = repo.get_config_stack() 

3356 

3357 if validate_path_element is None: 

3358 # Derive the validator from config so callers cannot accidentally 

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

3360 # omitting this argument. 

3361 validate_path_element = get_path_element_validator(config) 

3362 

3363 index = repo.open_index(config=config) 

3364 in_sparse_checkout = _sparse_checkout_matcher(repo, config, tree_encoding) 

3365 

3366 # Convert iterator to list since we need multiple passes 

3367 changes = list(change_iterator) 

3368 

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

3370 import platform 

3371 

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

3373 config = repo.get_config() 

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

3375 

3376 if ignore_case: 

3377 config = repo.get_config() 

3378 changes = detect_case_only_renames(changes, config) 

3379 

3380 if not allow_overwrite_modified: 

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

3382 paths_becoming_dirs = set() 

3383 for change in changes: 

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

3385 assert change.new is not None 

3386 path = change.new.path 

3387 assert path is not None 

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

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

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

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

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

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

3394 for other_change in changes: 

3395 if ( 

3396 other_change.type == CHANGE_DELETE 

3397 and other_change.old 

3398 and other_change.old.path == parent 

3399 ): 

3400 paths_becoming_dirs.add(parent) 

3401 

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

3403 for path in paths_becoming_dirs: 

3404 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3405 try: 

3406 current_stat = os.lstat(full_path) 

3407 except FileNotFoundError: 

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

3409 except OSError as e: 

3410 raise OSError( 

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

3412 ) from e 

3413 

3414 if stat.S_ISREG(current_stat.st_mode): 

3415 # Find the old entry for this path 

3416 old_change = None 

3417 for change in changes: 

3418 if ( 

3419 change.type == CHANGE_DELETE 

3420 and change.old 

3421 and change.old.path == path 

3422 ): 

3423 old_change = change 

3424 break 

3425 

3426 if old_change: 

3427 # Check if file has been modified 

3428 assert old_change.old is not None 

3429 assert ( 

3430 old_change.old.sha is not None 

3431 and old_change.old.mode is not None 

3432 ) 

3433 file_matches = _check_file_matches( 

3434 repo.object_store, 

3435 full_path, 

3436 old_change.old.sha, 

3437 old_change.old.mode, 

3438 current_stat, 

3439 honor_filemode, 

3440 blob_normalizer, 

3441 path, 

3442 ) 

3443 if not file_matches: 

3444 raise OSError( 

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

3446 ) 

3447 

3448 # Check for uncommitted modifications before making any changes 

3449 if not allow_overwrite_modified and old_tree_id: 

3450 for change in changes: 

3451 # Only check files that are being modified or deleted 

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

3453 path = change.old.path 

3454 assert path is not None 

3455 if not validate_path(path, validate_path_element): 

3456 continue 

3457 

3458 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3459 try: 

3460 current_stat = os.lstat(full_path) 

3461 except FileNotFoundError: 

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

3463 except OSError as e: 

3464 raise OSError( 

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

3466 ) from e 

3467 

3468 if stat.S_ISREG(current_stat.st_mode): 

3469 # Check if working tree file differs from old tree 

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

3471 file_matches = _check_file_matches( 

3472 repo.object_store, 

3473 full_path, 

3474 change.old.sha, 

3475 change.old.mode, 

3476 current_stat, 

3477 honor_filemode, 

3478 blob_normalizer, 

3479 path, 

3480 ) 

3481 if not file_matches: 

3482 from .errors import WorkingTreeModifiedError 

3483 

3484 raise WorkingTreeModifiedError( 

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

3486 f"would be overwritten by checkout. " 

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

3488 ) 

3489 

3490 # Apply deletes before adds so that a directory being replaced by a file 

3491 # has its contents removed first (and vice versa, a file being replaced by 

3492 # a directory is removed before its new children are written). 

3493 for change in changes: 

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

3495 # Remove file/directory 

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

3497 path = change.old.path 

3498 if not validate_path(path, validate_path_element): 

3499 continue 

3500 

3501 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3502 try: 

3503 verify_leading_dirs(path, [], repo_path) 

3504 except InvalidPathError: 

3505 # Removing through the symlink would unlink a path outside 

3506 # the work tree; only drop the index entry, as git does. 

3507 logger.warning( 

3508 "Not removing %r: leading path is not a real directory", path 

3509 ) 

3510 _transition_to_absent(repo, path, full_path, None, index) 

3511 continue 

3512 try: 

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

3514 except FileNotFoundError: 

3515 delete_stat = None 

3516 except OSError as e: 

3517 raise OSError( 

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

3519 ) from e 

3520 

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

3522 

3523 for change in changes: 

3524 if change.type in ( 

3525 CHANGE_ADD, 

3526 CHANGE_MODIFY, 

3527 CHANGE_UNCHANGED, 

3528 CHANGE_COPY, 

3529 CHANGE_RENAME, 

3530 ): 

3531 # Add or modify file 

3532 assert ( 

3533 change.new is not None 

3534 and change.new.path is not None 

3535 and change.new.mode is not None 

3536 and change.new.sha is not None 

3537 ) 

3538 path = change.new.path 

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

3540 # leaving any changes already applied in place. 

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

3542 full_path = _tree_to_fs_path(repo_path, path, tree_encoding) 

3543 try: 

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

3545 except FileNotFoundError: 

3546 modify_stat = None 

3547 except OSError as e: 

3548 raise OSError( 

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

3550 ) from e 

3551 

3552 if in_sparse_checkout is not None and not in_sparse_checkout(path): 

3553 # Only record paths outside the sparse checkout in the index. 

3554 # Remove them from disk only if they were checked out before, 

3555 # leaving untracked files alone. 

3556 try: 

3557 old_entry = index[path] 

3558 except KeyError: 

3559 pass 

3560 else: 

3561 if not ( 

3562 isinstance(old_entry, IndexEntry) and old_entry.skip_worktree 

3563 ): 

3564 _transition_to_absent(repo, path, full_path, modify_stat, index) 

3565 index[path] = _skip_worktree_entry(change.new.sha, change.new.mode) 

3566 elif S_ISGITLINK(change.new.mode): 

3567 _transition_to_submodule( 

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

3569 ) 

3570 else: 

3571 _transition_to_file( 

3572 repo.object_store, 

3573 path, 

3574 full_path, 

3575 modify_stat, 

3576 change.new, 

3577 index, 

3578 honor_filemode, 

3579 symlink_fn, 

3580 blob_normalizer, 

3581 tree_encoding, 

3582 ) 

3583 if in_sparse_checkout is not None and in_sparse_checkout(path): 

3584 # The path is now checked out, so a skip-worktree bit kept 

3585 # from the old entry no longer applies. 

3586 checked_out = index[path] 

3587 assert isinstance(checked_out, IndexEntry) 

3588 checked_out.set_skip_worktree(False) 

3589 

3590 index.write() 

3591 

3592 

3593def _stat_matches_entry( 

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

3595) -> bool: 

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

3597 

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

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

3600 

3601 Args: 

3602 st: Filesystem stat result 

3603 entry: Index entry to compare against 

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

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

3606 """ 

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

3608 if trust_ctime: 

3609 # Get entry ctime with nanosecond precision if available 

3610 if isinstance(entry.ctime, tuple): 

3611 entry_ctime_sec = entry.ctime[0] 

3612 entry_ctime_nsec = entry.ctime[1] 

3613 else: 

3614 entry_ctime_sec = int(entry.ctime) 

3615 entry_ctime_nsec = 0 

3616 

3617 if hasattr(st, "st_ctime_ns"): 

3618 # Use nanosecond precision when available 

3619 st_ctime_nsec = st.st_ctime_ns 

3620 entry_ctime_nsec_total = entry_ctime_sec * 1_000_000_000 + entry_ctime_nsec 

3621 if st_ctime_nsec != entry_ctime_nsec_total: 

3622 return False 

3623 else: 

3624 # Fall back to second precision 

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

3626 return False 

3627 

3628 # Get entry mtime with nanosecond precision if available 

3629 if isinstance(entry.mtime, tuple): 

3630 entry_mtime_sec = entry.mtime[0] 

3631 entry_mtime_nsec = entry.mtime[1] 

3632 else: 

3633 entry_mtime_sec = int(entry.mtime) 

3634 entry_mtime_nsec = 0 

3635 

3636 # Compare modification time with nanosecond precision if available 

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

3638 # modified multiple times within the same second 

3639 if hasattr(st, "st_mtime_ns"): 

3640 # Use nanosecond precision when available 

3641 st_mtime_nsec = st.st_mtime_ns 

3642 entry_mtime_nsec_total = entry_mtime_sec * 1_000_000_000 + entry_mtime_nsec 

3643 if st_mtime_nsec != entry_mtime_nsec_total: 

3644 return False 

3645 else: 

3646 # Fall back to second precision 

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

3648 return False 

3649 

3650 # Compare file size 

3651 if st.st_size != entry.size: 

3652 return False 

3653 

3654 # If all checks pass, file is likely unchanged 

3655 return True 

3656 

3657 

3658def _check_entry_for_changes( 

3659 tree_path: bytes, 

3660 entry: IndexEntry | ConflictedIndexEntry, 

3661 root_path: bytes, 

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

3663 trust_ctime: bool = True, 

3664 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

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

3666) -> bytes | None: 

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

3668 

3669 Args: 

3670 tree_path: Path in the tree 

3671 entry: Index entry to check 

3672 root_path: Root filesystem path 

3673 filter_blob_callback: Optional callback to filter blobs 

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

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

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

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

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

3679 Returns: tree_path if changed, None otherwise 

3680 """ 

3681 if isinstance(entry, ConflictedIndexEntry): 

3682 # Conflicted files are always unstaged 

3683 return tree_path 

3684 

3685 if entry.skip_worktree: 

3686 # Paths outside the sparse checkout are not expected on disk. 

3687 return None 

3688 

3689 full_path = _tree_to_fs_path(root_path, tree_path) 

3690 try: 

3691 st = os.lstat(full_path) 

3692 if stat.S_ISDIR(st.st_mode): 

3693 if _has_directory_changed(tree_path, entry): 

3694 return tree_path 

3695 return None 

3696 

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

3698 return None 

3699 

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

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

3702 # performance improvement for repositories with many unchanged files. 

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

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

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

3706 # where filter operations can be very slow. 

3707 if _stat_matches_entry(st, entry, trust_ctime): 

3708 return None 

3709 

3710 blob = blob_from_path_and_stat(full_path, st) 

3711 

3712 if filter_blob_callback is not None: 

3713 blob = filter_blob_callback(blob, tree_path) 

3714 except FileNotFoundError: 

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

3716 # different from whatever file used to exist. 

3717 return tree_path 

3718 else: 

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

3720 return tree_path 

3721 if refresh_stat is not None: 

3722 refresh_stat.append((tree_path, st)) 

3723 return None 

3724 

3725 

3726def get_unstaged_changes( 

3727 index: Index, 

3728 root_path: str | bytes, 

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

3730 preload_index: bool = False, 

3731 trust_ctime: bool = True, 

3732 max_stat: int | None = None, 

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

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

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

3736 

3737 Args: 

3738 index: index to check 

3739 root_path: path in which to find files 

3740 filter_blob_callback: Optional callback to filter blobs 

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

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

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

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

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

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

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

3748 second pass. 

3749 Returns: iterator over paths with unstaged changes 

3750 """ 

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

3752 if not isinstance(root_path, bytes): 

3753 root_path = os.fsencode(root_path) 

3754 

3755 stat_count = 0 

3756 

3757 if preload_index: 

3758 # Use parallel processing for better performance on slow filesystems 

3759 try: 

3760 import multiprocessing 

3761 from concurrent.futures import ThreadPoolExecutor 

3762 except ImportError: 

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

3764 preload_index = False 

3765 else: 

3766 # Collect all entries first 

3767 entries = list(index.iteritems()) 

3768 

3769 if max_stat is not None: 

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

3771 entries = entries[:max_stat] 

3772 

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

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

3775 

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

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

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

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

3780 ] 

3781 

3782 # Process entries in parallel 

3783 with ThreadPoolExecutor(max_workers=num_workers) as executor: 

3784 # Submit all tasks 

3785 futures = [ 

3786 executor.submit( 

3787 _check_entry_for_changes, 

3788 tree_path, 

3789 entry, 

3790 root_path, 

3791 filter_blob_callback, 

3792 trust_ctime, 

3793 index.object_format, 

3794 worker_refresh[i], 

3795 ) 

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

3797 ] 

3798 

3799 # Yield results as they complete 

3800 for future in futures: 

3801 result = future.result() 

3802 if result is not None: 

3803 yield result 

3804 

3805 if refresh_stat is not None: 

3806 for bucket in worker_refresh: 

3807 if bucket: 

3808 refresh_stat.extend(bucket) 

3809 

3810 if not preload_index: 

3811 # Serial processing 

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

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

3814 return 

3815 result = _check_entry_for_changes( 

3816 tree_path, 

3817 entry, 

3818 root_path, 

3819 filter_blob_callback, 

3820 trust_ctime, 

3821 index.object_format, 

3822 refresh_stat, 

3823 ) 

3824 stat_count += 1 

3825 if result is not None: 

3826 yield result 

3827 

3828 

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

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

3831 

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

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

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

3835 

3836 Rules: 

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

3838 > U+10FFFF) is decoded normally. 

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

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

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

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

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

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

3845 """ 

3846 out: list[str] = [] 

3847 i = 0 

3848 n = len(data) 

3849 while i < n: 

3850 c = data[i] 

3851 if c < 0x80: 

3852 out.append(chr(c)) 

3853 i += 1 

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

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

3856 out.append(chr(cp)) 

3857 i += 2 

3858 elif ( 

3859 0xE0 <= c < 0xF0 

3860 and i + 2 < n 

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

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

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

3864 ): 

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

3866 out.append(chr(cp)) 

3867 i += 3 

3868 elif ( 

3869 0xF0 <= c < 0xF5 

3870 and i + 3 < n 

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

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

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

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

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

3876 ): 

3877 cp = ( 

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

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

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

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

3882 ) 

3883 out.append(chr(cp)) 

3884 i += 4 

3885 elif c >= 0xA0: 

3886 out.append(chr(c)) 

3887 i += 1 

3888 else: 

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

3890 i += 1 

3891 return "".join(out) 

3892 

3893 

3894def _tree_to_fs_path( 

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

3896) -> bytes: 

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

3898 

3899 Args: 

3900 root_path: Root filesystem path 

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

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

3903 

3904 Returns: File system path. 

3905 """ 

3906 assert isinstance(tree_path, bytes) 

3907 if os_sep_bytes != b"/": 

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

3909 else: 

3910 sep_corrected_path = tree_path 

3911 

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

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

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

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

3916 # propagate rather than silently producing a corrupt path. 

3917 if sys.platform == "win32": 

3918 if tree_encoding == "utf-8": 

3919 tree_path_str = _decode_utf8_with_fallback(sep_corrected_path) 

3920 else: 

3921 tree_path_str = sep_corrected_path.decode(tree_encoding) 

3922 sep_corrected_path = os.fsencode(tree_path_str) 

3923 

3924 return os.path.join(root_path, sep_corrected_path) 

3925 

3926 

3927def _fs_to_tree_path(fs_path: str | bytes, tree_encoding: str = "utf-8") -> bytes: 

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

3929 

3930 Args: 

3931 fs_path: File system path. 

3932 tree_encoding: Encoding to use for tree paths (default: utf-8) 

3933 

3934 Returns: Git tree path as bytes (encoded with tree_encoding) 

3935 """ 

3936 if not isinstance(fs_path, bytes): 

3937 fs_path_bytes = os.fsencode(fs_path) 

3938 else: 

3939 fs_path_bytes = fs_path 

3940 

3941 # On Windows the on-disk filename is a UTF-16 wide string; Python gives 

3942 # us either str (already decoded) or bytes encoded via the filesystem 

3943 # codec. Normalise to str, then encode under the tree encoding so the 

3944 # resulting tree path is plain UTF-8. This matches C git's xwcstoutf, 

3945 # which is just WideCharToMultiByte(CP_UTF8); it makes no attempt to 

3946 # reverse the xutftowcsn fallbacks, so a file that was checked out from 

3947 # a tree path with invalid UTF-8 will read back as the lossy form (the 

3948 # same divergence C git exhibits, documented as a one-way mapping). 

3949 if sys.platform == "win32": 

3950 fs_path_str = os.fsdecode(fs_path_bytes) 

3951 fs_path_bytes = fs_path_str.encode(tree_encoding) 

3952 

3953 if os_sep_bytes != b"/": 

3954 tree_path = fs_path_bytes.replace(os_sep_bytes, b"/") 

3955 else: 

3956 tree_path = fs_path_bytes 

3957 return tree_path 

3958 

3959 

3960def index_entry_from_directory(st: os.stat_result, path: bytes) -> IndexEntry | None: 

3961 """Create an index entry for a directory. 

3962 

3963 This is only used for submodules (directories containing .git). 

3964 

3965 Args: 

3966 st: Stat result for the directory 

3967 path: Path to the directory 

3968 

3969 Returns: 

3970 IndexEntry for a submodule, or None if not a submodule 

3971 """ 

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

3973 head = read_submodule_head(path) 

3974 if head is None: 

3975 return None 

3976 return index_entry_from_stat(st, head, mode=S_IFGITLINK) 

3977 return None 

3978 

3979 

3980def index_entry_from_path( 

3981 path: bytes, 

3982 object_store: ObjectContainer | None = None, 

3983 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

3984) -> IndexEntry | None: 

3985 """Create an index from a filesystem path. 

3986 

3987 This returns an index value for files, symlinks 

3988 and tree references. for directories and 

3989 non-existent files it returns None 

3990 

3991 Args: 

3992 path: Path to create an index entry for 

3993 object_store: Optional object store to 

3994 save new blobs in 

3995 object_format: Object format to name new blobs with 

3996 Returns: An index entry; None for directories 

3997 """ 

3998 assert isinstance(path, bytes) 

3999 st = os.lstat(path) 

4000 if stat.S_ISDIR(st.st_mode): 

4001 return index_entry_from_directory(st, path) 

4002 

4003 if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode): 

4004 blob = blob_from_path_and_stat(path, st) 

4005 if object_store is not None: 

4006 object_store.add_object(blob) 

4007 return index_entry_from_stat(st, ObjectID(blob.get_id(object_format))) 

4008 

4009 return None 

4010 

4011 

4012def iter_fresh_entries( 

4013 paths: Iterable[bytes], 

4014 root_path: bytes, 

4015 object_store: ObjectContainer | None = None, 

4016 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

4017) -> Iterator[tuple[bytes, IndexEntry | None]]: 

4018 """Iterate over current versions of index entries on disk. 

4019 

4020 Args: 

4021 paths: Paths to iterate over 

4022 root_path: Root path to access from 

4023 object_store: Optional store to save new blobs in 

4024 object_format: Object format to name new blobs with 

4025 Returns: Iterator over path, index_entry 

4026 """ 

4027 for path in paths: 

4028 p = _tree_to_fs_path(root_path, path) 

4029 try: 

4030 entry = index_entry_from_path( 

4031 p, object_store=object_store, object_format=object_format 

4032 ) 

4033 except (FileNotFoundError, IsADirectoryError): 

4034 entry = None 

4035 yield path, entry 

4036 

4037 

4038def iter_fresh_objects( 

4039 paths: Iterable[bytes], 

4040 root_path: bytes, 

4041 include_deleted: bool = False, 

4042 object_store: ObjectContainer | None = None, 

4043 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

4044) -> Iterator[tuple[bytes, ObjectID | None, int | None]]: 

4045 """Iterate over versions of objects on disk referenced by index. 

4046 

4047 Args: 

4048 paths: Paths to check 

4049 root_path: Root path to access from 

4050 include_deleted: Include deleted entries with sha and 

4051 mode set to None 

4052 object_store: Optional object store to report new items to 

4053 object_format: Object format to name new blobs with 

4054 Returns: Iterator over path, sha, mode 

4055 """ 

4056 for path, entry in iter_fresh_entries( 

4057 paths, root_path, object_store=object_store, object_format=object_format 

4058 ): 

4059 if entry is None: 

4060 if include_deleted: 

4061 yield path, None, None 

4062 else: 

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

4064 

4065 

4066def refresh_index(index: Index, root_path: bytes) -> None: 

4067 """Refresh the contents of an index. 

4068 

4069 This is the equivalent to running 'git commit -a'. 

4070 

4071 Args: 

4072 index: Index to update 

4073 root_path: Root filesystem path 

4074 """ 

4075 for path, entry in iter_fresh_entries( 

4076 index, root_path, object_format=index.object_format 

4077 ): 

4078 if entry: 

4079 index[path] = entry 

4080 

4081 

4082def apply_stat_refresh( 

4083 index: Index, refreshed: Iterable[tuple[bytes, os.stat_result]] 

4084) -> bool: 

4085 """Apply refreshed stat info collected during an index walk. 

4086 

4087 ``refreshed`` should be the list populated by passing ``refresh_stat`` 

4088 into `get_unstaged_changes`: it contains (tree_path, stat_result) 

4089 pairs for entries whose on-disk stat drifted but whose content still 

4090 matches the index entry sha. 

4091 

4092 Returns True if any entries were updated (caller may want to write 

4093 the index). 

4094 """ 

4095 updated = False 

4096 for tree_path, st in refreshed: 

4097 entry = index[tree_path] 

4098 if isinstance(entry, ConflictedIndexEntry): 

4099 continue 

4100 index[tree_path] = index_entry_from_stat( 

4101 st, 

4102 entry.sha, 

4103 mode=entry.mode, 

4104 flags=entry.flags, 

4105 extended_flags=entry.extended_flags, 

4106 ) 

4107 updated = True 

4108 return updated 

4109 

4110 

4111class locked_index: 

4112 """Lock the index while making modifications. 

4113 

4114 Works as a context manager. 

4115 """ 

4116 

4117 _file: "_GitFile" 

4118 

4119 def __init__( 

4120 self, 

4121 path: bytes | str, 

4122 object_format: ObjectFormat = DEFAULT_OBJECT_FORMAT, 

4123 ) -> None: 

4124 """Initialize locked_index. 

4125 

4126 Args: 

4127 path: Path to the index file 

4128 object_format: Object format of the repository owning the index 

4129 """ 

4130 self._path = path 

4131 self._object_format = object_format 

4132 

4133 def __enter__(self) -> Index: 

4134 """Enter context manager and lock index.""" 

4135 f = GitFile(self._path, "wb") 

4136 self._file = f 

4137 self._index = Index(self._path, object_format=self._object_format) 

4138 return self._index 

4139 

4140 def __exit__( 

4141 self, 

4142 exc_type: type | None, 

4143 exc_value: BaseException | None, 

4144 traceback: types.TracebackType | None, 

4145 ) -> None: 

4146 """Exit context manager and unlock index.""" 

4147 if exc_type is not None: 

4148 self._file.abort() 

4149 return 

4150 try: 

4151 f = IndexChecksumWriter(self._file, self._object_format) 

4152 write_index_dict(f, self._index._byname, object_format=self._object_format) 

4153 except BaseException: 

4154 self._file.abort() 

4155 else: 

4156 f.close()