diff.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. # diff.py -- Diff functionality for Dulwich
  2. # Copyright (C) 2025 Dulwich contributors
  3. #
  4. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  5. # General Public License as published by the Free Software Foundation; version 2.0
  6. # or (at your option) any later version. You can redistribute it and/or
  7. # modify it under the terms of either of these two licenses.
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. #
  15. # You should have received a copy of the licenses; if not, see
  16. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  17. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  18. # License, Version 2.0.
  19. #
  20. """Diff functionality with separate codepaths.
  21. This module provides three main functions for different diff scenarios:
  22. 1. diff_index_to_tree: Shows staged changes (index vs commit)
  23. Used by: git diff --staged, git diff --cached
  24. 2. diff_working_tree_to_tree: Shows all changes from a commit to working tree
  25. Used by: git diff <commit>
  26. 3. diff_working_tree_to_index: Shows unstaged changes (working tree vs index)
  27. Used by: git diff (with no arguments)
  28. Example usage:
  29. from dulwich.repo import Repo
  30. from dulwich.diff import diff_index_to_tree
  31. import sys
  32. repo = Repo('.')
  33. # Show staged changes
  34. diff_index_to_tree(repo, sys.stdout.buffer)
  35. # Show changes in specific paths only
  36. diff_index_to_tree(repo, sys.stdout.buffer, paths=[b'src/', b'README.md'])
  37. """
  38. import io
  39. import logging
  40. import os
  41. import stat
  42. from collections.abc import Iterable, Sequence
  43. from typing import BinaryIO
  44. from ._typing import Buffer
  45. from .index import ConflictedIndexEntry, commit_index
  46. from .object_store import iter_tree_contents
  47. from .objects import S_ISGITLINK, Blob, Commit, ObjectID
  48. from .patch import write_blob_diff, write_object_diff
  49. from .repo import Repo
  50. logger = logging.getLogger(__name__)
  51. def should_include_path(path: bytes, paths: Sequence[bytes] | None) -> bool:
  52. """Check if a path should be included based on path filters.
  53. Args:
  54. path: The path to check
  55. paths: List of path filters, or None for no filtering
  56. Returns:
  57. True if the path should be included
  58. """
  59. if not paths:
  60. return True
  61. return any(path == p or path.startswith(p + b"/") for p in paths)
  62. def diff_index_to_tree(
  63. repo: Repo,
  64. outstream: BinaryIO,
  65. commit_sha: ObjectID | None = None,
  66. paths: Sequence[bytes] | None = None,
  67. diff_algorithm: str | None = None,
  68. ) -> None:
  69. """Show staged changes (index vs commit).
  70. Args:
  71. repo: Repository object
  72. outstream: Stream to write diff to
  73. commit_sha: SHA of commit to compare against, or None for HEAD
  74. paths: Optional list of paths to filter (as bytes)
  75. diff_algorithm: Algorithm to use for diffing ("myers" or "patience"), defaults to DEFAULT_DIFF_ALGORITHM if None
  76. """
  77. if commit_sha is None:
  78. try:
  79. from dulwich.refs import HEADREF
  80. commit_sha = repo.refs[HEADREF]
  81. old_commit = repo[commit_sha]
  82. assert isinstance(old_commit, Commit)
  83. old_tree = old_commit.tree
  84. except KeyError:
  85. # No HEAD means no commits yet
  86. old_tree = None
  87. else:
  88. old_commit = repo[commit_sha]
  89. assert isinstance(old_commit, Commit)
  90. old_tree = old_commit.tree
  91. # Get tree from index
  92. index = repo.open_index()
  93. new_tree = commit_index(repo.object_store, index)
  94. changes = repo.object_store.tree_changes(old_tree, new_tree, paths=paths)
  95. for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes:
  96. write_object_diff(
  97. outstream,
  98. repo.object_store,
  99. (oldpath, oldmode, oldsha),
  100. (newpath, newmode, newsha),
  101. diff_algorithm=diff_algorithm,
  102. )
  103. def diff_working_tree_to_tree(
  104. repo: Repo,
  105. outstream: BinaryIO,
  106. commit_sha: ObjectID,
  107. paths: Sequence[bytes] | None = None,
  108. diff_algorithm: str | None = None,
  109. ) -> None:
  110. """Compare working tree to a specific commit.
  111. Args:
  112. repo: Repository object
  113. outstream: Stream to write diff to
  114. commit_sha: SHA of commit to compare against
  115. paths: Optional list of paths to filter (as bytes)
  116. diff_algorithm: Algorithm to use for diffing ("myers" or "patience"), defaults to DEFAULT_DIFF_ALGORITHM if None
  117. """
  118. commit = repo[commit_sha]
  119. assert isinstance(commit, Commit)
  120. tree = commit.tree
  121. normalizer = repo.get_blob_normalizer()
  122. filter_callback = normalizer.checkin_normalize if normalizer is not None else None
  123. # Get index for tracking new files
  124. index = repo.open_index()
  125. index_paths = set(index.paths())
  126. processed_paths = set()
  127. # Process files from the committed tree lazily
  128. for entry in iter_tree_contents(repo.object_store, tree):
  129. assert (
  130. entry.path is not None and entry.mode is not None and entry.sha is not None
  131. )
  132. path = entry.path
  133. if not should_include_path(path, paths):
  134. continue
  135. processed_paths.add(path)
  136. full_path = os.path.join(repo.path, path.decode("utf-8"))
  137. # Get the old file from tree
  138. old_mode = entry.mode
  139. old_sha = entry.sha
  140. old_blob = repo.object_store[old_sha]
  141. assert isinstance(old_blob, Blob)
  142. try:
  143. # Use lstat to handle symlinks properly
  144. st = os.lstat(full_path)
  145. except FileNotFoundError:
  146. # File was deleted
  147. if old_blob is not None:
  148. write_blob_diff(
  149. outstream, (path, old_mode, old_blob), (None, None, None)
  150. )
  151. except PermissionError:
  152. logger.warning("%s: Permission denied", path.decode())
  153. # Show as deletion if it was in tree
  154. if old_blob is not None:
  155. write_blob_diff(
  156. outstream, (path, old_mode, old_blob), (None, None, None)
  157. )
  158. except OSError as e:
  159. logger.warning("%s: %s", path.decode(), e)
  160. # Show as deletion if it was in tree
  161. if old_blob is not None:
  162. write_blob_diff(
  163. outstream, (path, old_mode, old_blob), (None, None, None)
  164. )
  165. else:
  166. # Handle different file types
  167. if stat.S_ISDIR(st.st_mode):
  168. if old_blob is not None:
  169. # Directory in working tree where file was expected
  170. if stat.S_ISLNK(old_mode):
  171. logger.warning("%s: symlink became a directory", path.decode())
  172. else:
  173. logger.warning("%s: file became a directory", path.decode())
  174. # Show as deletion
  175. write_blob_diff(
  176. outstream, (path, old_mode, old_blob), (None, None, None)
  177. )
  178. # If old_blob is None, it's a new directory - skip it
  179. continue
  180. elif stat.S_ISLNK(st.st_mode):
  181. # Symlink in working tree
  182. target = os.readlink(full_path).encode("utf-8")
  183. new_blob = Blob()
  184. new_blob.data = target
  185. if old_blob is None:
  186. # New symlink
  187. write_blob_diff(
  188. outstream,
  189. (None, None, None),
  190. (path, stat.S_IFLNK | 0o777, new_blob),
  191. )
  192. elif not stat.S_ISLNK(old_mode):
  193. # Type change: file/submodule -> symlink
  194. write_blob_diff(
  195. outstream,
  196. (path, old_mode, old_blob),
  197. (path, stat.S_IFLNK | 0o777, new_blob),
  198. )
  199. elif old_blob is not None and old_blob.data != target:
  200. # Symlink target changed
  201. write_blob_diff(
  202. outstream,
  203. (path, old_mode, old_blob),
  204. (path, old_mode, new_blob),
  205. )
  206. elif stat.S_ISREG(st.st_mode):
  207. # Regular file
  208. with open(full_path, "rb") as f:
  209. new_content = f.read()
  210. # Create a temporary blob for filtering and comparison
  211. new_blob = Blob()
  212. new_blob.data = new_content
  213. # Apply filters if needed (only for regular files, not gitlinks)
  214. if filter_callback is not None and (
  215. old_blob is None or not S_ISGITLINK(old_mode)
  216. ):
  217. new_blob = filter_callback(new_blob, path)
  218. # Determine the git mode for the new file
  219. if st.st_mode & stat.S_IXUSR:
  220. new_git_mode = stat.S_IFREG | 0o755
  221. else:
  222. new_git_mode = stat.S_IFREG | 0o644
  223. if old_blob is None:
  224. # New file
  225. write_blob_diff(
  226. outstream, (None, None, None), (path, new_git_mode, new_blob)
  227. )
  228. elif stat.S_ISLNK(old_mode):
  229. # Symlink -> file
  230. write_blob_diff(
  231. outstream,
  232. (path, old_mode, old_blob),
  233. (path, new_git_mode, new_blob),
  234. )
  235. elif S_ISGITLINK(old_mode):
  236. # Submodule -> file
  237. write_blob_diff(
  238. outstream,
  239. (path, old_mode, old_blob),
  240. (path, new_git_mode, new_blob),
  241. )
  242. else:
  243. # Regular file, check for content or mode changes
  244. old_git_mode = old_mode & (stat.S_IFREG | 0o777)
  245. if (
  246. old_blob is not None and old_blob.data != new_blob.data
  247. ) or old_git_mode != new_git_mode:
  248. write_blob_diff(
  249. outstream,
  250. (path, old_mode, old_blob),
  251. (path, new_git_mode, new_blob),
  252. )
  253. elif stat.S_ISFIFO(st.st_mode):
  254. logger.warning("%s: unsupported file type (fifo)", path.decode())
  255. if old_blob is not None:
  256. write_blob_diff(
  257. outstream, (path, old_mode, old_blob), (None, None, None)
  258. )
  259. elif stat.S_ISSOCK(st.st_mode):
  260. logger.warning("%s: unsupported file type (socket)", path.decode())
  261. if old_blob is not None:
  262. write_blob_diff(
  263. outstream, (path, old_mode, old_blob), (None, None, None)
  264. )
  265. else:
  266. logger.warning("%s: unsupported file type", path.decode())
  267. if old_blob is not None:
  268. write_blob_diff(
  269. outstream, (path, old_mode, old_blob), (None, None, None)
  270. )
  271. # Now process any new files from index that weren't in the tree
  272. for path in sorted(index_paths - processed_paths):
  273. if not should_include_path(path, paths):
  274. continue
  275. full_path = os.path.join(repo.path, path.decode("utf-8"))
  276. try:
  277. # Use lstat to handle symlinks properly
  278. st = os.lstat(full_path)
  279. except FileNotFoundError:
  280. # New file already deleted, skip
  281. continue
  282. except PermissionError:
  283. logger.warning("%s: Permission denied", path.decode())
  284. continue
  285. except OSError as e:
  286. logger.warning("%s: %s", path.decode(), e)
  287. continue
  288. # Handle different file types for new files
  289. if stat.S_ISDIR(st.st_mode):
  290. # New directory - skip it
  291. continue
  292. elif stat.S_ISLNK(st.st_mode):
  293. # New symlink
  294. target = os.readlink(full_path).encode("utf-8")
  295. new_blob = Blob()
  296. new_blob.data = target
  297. write_blob_diff(
  298. outstream,
  299. (None, None, None),
  300. (path, stat.S_IFLNK | 0o777, new_blob),
  301. )
  302. elif stat.S_ISREG(st.st_mode):
  303. # New regular file
  304. with open(full_path, "rb") as f:
  305. new_content = f.read()
  306. new_blob = Blob()
  307. new_blob.data = new_content
  308. # Apply filters if needed
  309. if filter_callback is not None:
  310. new_blob = filter_callback(new_blob, path)
  311. # Determine the git mode for the new file
  312. if st.st_mode & stat.S_IXUSR:
  313. new_git_mode = 0o100755
  314. else:
  315. new_git_mode = 0o100644
  316. write_blob_diff(
  317. outstream, (None, None, None), (path, new_git_mode, new_blob)
  318. )
  319. elif stat.S_ISFIFO(st.st_mode):
  320. logger.warning("%s: unsupported file type (fifo)", path.decode())
  321. elif stat.S_ISSOCK(st.st_mode):
  322. logger.warning("%s: unsupported file type (socket)", path.decode())
  323. else:
  324. logger.warning("%s: unsupported file type", path.decode())
  325. def diff_working_tree_to_index(
  326. repo: Repo,
  327. outstream: BinaryIO,
  328. paths: Sequence[bytes] | None = None,
  329. diff_algorithm: str | None = None,
  330. ) -> None:
  331. """Compare working tree to index.
  332. Args:
  333. repo: Repository object
  334. outstream: Stream to write diff to
  335. paths: Optional list of paths to filter (as bytes)
  336. diff_algorithm: Algorithm to use for diffing ("myers" or "patience"), defaults to DEFAULT_DIFF_ALGORITHM if None
  337. """
  338. index = repo.open_index()
  339. normalizer = repo.get_blob_normalizer()
  340. filter_callback = normalizer.checkin_normalize if normalizer is not None else None
  341. # Process each file in the index
  342. for tree_path, entry in index.iteritems():
  343. if not should_include_path(tree_path, paths):
  344. continue
  345. # Handle conflicted entries by using stage 2 ("ours")
  346. if isinstance(entry, ConflictedIndexEntry):
  347. if entry.this is None:
  348. continue # No stage 2 entry, skip
  349. old_mode = entry.this.mode
  350. old_sha = entry.this.sha
  351. else:
  352. # Get file from regular index entry
  353. old_mode = entry.mode
  354. old_sha = entry.sha
  355. old_obj = repo.object_store[old_sha]
  356. # Type check and cast to Blob
  357. if isinstance(old_obj, Blob):
  358. old_blob = old_obj
  359. else:
  360. old_blob = None
  361. full_path = os.path.join(repo.path, tree_path.decode("utf-8"))
  362. try:
  363. # Use lstat to handle symlinks properly
  364. st = os.lstat(full_path)
  365. # Handle different file types
  366. if stat.S_ISDIR(st.st_mode):
  367. # Directory in working tree where file was expected
  368. if stat.S_ISLNK(old_mode):
  369. logger.warning("%s: symlink became a directory", tree_path.decode())
  370. else:
  371. logger.warning("%s: file became a directory", tree_path.decode())
  372. # Show as deletion
  373. write_blob_diff(
  374. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  375. )
  376. elif stat.S_ISLNK(st.st_mode):
  377. # Symlink in working tree
  378. target = os.readlink(full_path).encode("utf-8")
  379. new_blob = Blob()
  380. new_blob.data = target
  381. # Check if type changed or content changed
  382. if not stat.S_ISLNK(old_mode):
  383. # Type change: file/submodule -> symlink
  384. write_blob_diff(
  385. outstream,
  386. (tree_path, old_mode, old_blob),
  387. (tree_path, stat.S_IFLNK | 0o777, new_blob),
  388. )
  389. elif old_blob is not None and old_blob.data != target:
  390. # Symlink target changed
  391. write_blob_diff(
  392. outstream,
  393. (tree_path, old_mode, old_blob),
  394. (tree_path, old_mode, new_blob),
  395. )
  396. elif stat.S_ISREG(st.st_mode):
  397. # Regular file
  398. with open(full_path, "rb") as f:
  399. new_content = f.read()
  400. # Create a temporary blob for filtering and comparison
  401. new_blob = Blob()
  402. new_blob.data = new_content
  403. # Apply filters if needed (only for regular files)
  404. if filter_callback is not None and not S_ISGITLINK(old_mode):
  405. new_blob = filter_callback(new_blob, tree_path)
  406. # Determine the git mode for the new file
  407. if st.st_mode & stat.S_IXUSR:
  408. new_git_mode = stat.S_IFREG | 0o755
  409. else:
  410. new_git_mode = stat.S_IFREG | 0o644
  411. # Check if this was a type change
  412. if stat.S_ISLNK(old_mode):
  413. # Symlink -> file
  414. write_blob_diff(
  415. outstream,
  416. (tree_path, old_mode, old_blob),
  417. (tree_path, new_git_mode, new_blob),
  418. )
  419. elif S_ISGITLINK(old_mode):
  420. # Submodule -> file
  421. write_blob_diff(
  422. outstream,
  423. (tree_path, old_mode, old_blob),
  424. (tree_path, new_git_mode, new_blob),
  425. )
  426. else:
  427. # Regular file, check for content or mode changes
  428. old_git_mode = old_mode & (stat.S_IFREG | 0o777)
  429. if (
  430. old_blob is not None and old_blob.data != new_blob.data
  431. ) or old_git_mode != new_git_mode:
  432. write_blob_diff(
  433. outstream,
  434. (tree_path, old_mode, old_blob),
  435. (tree_path, new_git_mode, new_blob),
  436. )
  437. elif stat.S_ISFIFO(st.st_mode):
  438. logger.warning("%s: unsupported file type (fifo)", tree_path.decode())
  439. write_blob_diff(
  440. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  441. )
  442. elif stat.S_ISSOCK(st.st_mode):
  443. logger.warning("%s: unsupported file type (socket)", tree_path.decode())
  444. write_blob_diff(
  445. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  446. )
  447. else:
  448. logger.warning("%s: unsupported file type", tree_path.decode())
  449. write_blob_diff(
  450. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  451. )
  452. except FileNotFoundError:
  453. # File was deleted - this is normal, not a warning
  454. write_blob_diff(
  455. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  456. )
  457. except PermissionError:
  458. logger.warning("%s: Permission denied", tree_path.decode())
  459. # Show as deletion since we can't read it
  460. write_blob_diff(
  461. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  462. )
  463. except OSError as e:
  464. logger.warning("%s: %s", tree_path.decode(), e)
  465. # Show as deletion since we can't read it
  466. write_blob_diff(
  467. outstream, (tree_path, old_mode, old_blob), (None, None, None)
  468. )
  469. class ColorizedDiffStream(BinaryIO):
  470. """Stream wrapper that colorizes diff output line by line using Rich.
  471. This class wraps a binary output stream and applies color formatting
  472. to diff output as it's written. It processes data line by line to
  473. enable streaming colorization without buffering the entire diff.
  474. """
  475. @staticmethod
  476. def is_available() -> bool:
  477. """Check if Rich is available for colorization.
  478. Returns:
  479. bool: True if Rich can be imported, False otherwise
  480. """
  481. try:
  482. import importlib.util
  483. return importlib.util.find_spec("rich.console") is not None
  484. except ImportError:
  485. return False
  486. def __init__(self, output_stream: BinaryIO) -> None:
  487. """Initialize the colorized stream wrapper.
  488. Args:
  489. output_stream: The underlying binary stream to write to
  490. """
  491. self.output_stream = output_stream
  492. import io
  493. from rich.console import Console
  494. # Rich expects a text stream, so we need to wrap our binary stream
  495. self.text_wrapper = io.TextIOWrapper(
  496. output_stream, encoding="utf-8", newline=""
  497. )
  498. self.console = Console(file=self.text_wrapper, force_terminal=True)
  499. self.buffer = b""
  500. def write(self, data: bytes | Buffer) -> int: # type: ignore[override,unused-ignore]
  501. """Write data to the stream, applying colorization.
  502. Args:
  503. data: Bytes to write
  504. Returns:
  505. Number of bytes written
  506. """
  507. # Add new data to buffer
  508. if not isinstance(data, bytes):
  509. data = bytes(data)
  510. self.buffer += data
  511. # Process complete lines
  512. while b"\n" in self.buffer:
  513. line, self.buffer = self.buffer.split(b"\n", 1)
  514. self._colorize_and_write_line(line + b"\n")
  515. return len(data)
  516. def writelines(self, lines: Iterable[bytes | Buffer]) -> None: # type: ignore[override,unused-ignore]
  517. """Write a list of lines to the stream.
  518. Args:
  519. lines: Iterable of bytes to write
  520. """
  521. for line in lines:
  522. self.write(line)
  523. def _colorize_and_write_line(self, line_bytes: bytes) -> None:
  524. """Apply color formatting to a single line and write it.
  525. Args:
  526. line_bytes: The line to colorize and write (as bytes)
  527. """
  528. try:
  529. line = line_bytes.decode("utf-8", errors="replace")
  530. # Colorize based on diff line type
  531. if line.startswith("+") and not line.startswith("+++"):
  532. self.console.print(line, style="green", end="")
  533. elif line.startswith("-") and not line.startswith("---"):
  534. self.console.print(line, style="red", end="")
  535. elif line.startswith("@@"):
  536. self.console.print(line, style="cyan", end="")
  537. elif line.startswith(("+++", "---")):
  538. self.console.print(line, style="bold", end="")
  539. else:
  540. self.console.print(line, end="")
  541. except (UnicodeDecodeError, UnicodeEncodeError):
  542. # Fallback to raw output if we can't decode/encode the text
  543. self.output_stream.write(line_bytes)
  544. def flush(self) -> None:
  545. """Flush any remaining buffered content and the underlying stream."""
  546. # Write any remaining buffer content
  547. if self.buffer:
  548. self._colorize_and_write_line(self.buffer)
  549. self.buffer = b""
  550. # Flush the text wrapper and underlying stream
  551. if hasattr(self.text_wrapper, "flush"):
  552. self.text_wrapper.flush()
  553. if hasattr(self.output_stream, "flush"):
  554. self.output_stream.flush()
  555. # BinaryIO interface methods
  556. def close(self) -> None:
  557. """Close the stream."""
  558. self.flush()
  559. if hasattr(self.output_stream, "close"):
  560. self.output_stream.close()
  561. @property
  562. def closed(self) -> bool:
  563. """Check if the stream is closed."""
  564. return getattr(self.output_stream, "closed", False)
  565. def fileno(self) -> int:
  566. """Return the file descriptor."""
  567. return self.output_stream.fileno()
  568. def isatty(self) -> bool:
  569. """Check if the stream is a TTY."""
  570. return getattr(self.output_stream, "isatty", lambda: False)()
  571. def read(self, n: int = -1) -> bytes:
  572. """Read is not supported on this write-only stream."""
  573. raise io.UnsupportedOperation("not readable")
  574. def readable(self) -> bool:
  575. """This stream is not readable."""
  576. return False
  577. def readline(self, limit: int = -1) -> bytes:
  578. """Read is not supported on this write-only stream."""
  579. raise io.UnsupportedOperation("not readable")
  580. def readlines(self, hint: int = -1) -> list[bytes]:
  581. """Read is not supported on this write-only stream."""
  582. raise io.UnsupportedOperation("not readable")
  583. def seek(self, offset: int, whence: int = 0) -> int:
  584. """Seek is not supported on this stream."""
  585. raise io.UnsupportedOperation("not seekable")
  586. def seekable(self) -> bool:
  587. """This stream is not seekable."""
  588. return False
  589. def tell(self) -> int:
  590. """Tell is not supported on this stream."""
  591. raise io.UnsupportedOperation("not seekable")
  592. def truncate(self, size: int | None = None) -> int:
  593. """Truncate is not supported on this stream."""
  594. raise io.UnsupportedOperation("not truncatable")
  595. def writable(self) -> bool:
  596. """This stream is writable."""
  597. return True
  598. def __enter__(self) -> "ColorizedDiffStream":
  599. """Context manager entry."""
  600. return self
  601. def __exit__(
  602. self,
  603. exc_type: type[BaseException] | None,
  604. exc_val: BaseException | None,
  605. exc_tb: object | None,
  606. ) -> None:
  607. """Context manager exit."""
  608. self.flush()
  609. def __iter__(self) -> "ColorizedDiffStream":
  610. """Iterator interface - not supported."""
  611. raise io.UnsupportedOperation("not iterable")
  612. def __next__(self) -> bytes:
  613. """Iterator interface - not supported."""
  614. raise io.UnsupportedOperation("not iterable")