|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import os |
5 | 6 | import shutil |
| 7 | +import subprocess |
6 | 8 | from pathlib import Path |
7 | | -from typing import Any |
| 9 | +from typing import Any, Literal |
8 | 10 |
|
9 | 11 | from jinja2 import Environment, StrictUndefined, TemplateError |
10 | 12 |
|
|
18 | 20 | autoescape=False, |
19 | 21 | ) |
20 | 22 |
|
| 23 | +CopyMethod = Literal["reflink", "hardlink", "copy"] |
| 24 | + |
| 25 | + |
| 26 | +def copy_file_efficient( |
| 27 | + src: Path, |
| 28 | + dest: Path, |
| 29 | + *, |
| 30 | + allow_hardlink: bool = True, |
| 31 | +) -> CopyMethod: |
| 32 | + """Copy a file using reflink → hardlink → ``shutil.copy2`` (CNA parity). |
| 33 | +
|
| 34 | + Hardlinks are skipped when ``allow_hardlink`` is False (e.g. files that will |
| 35 | + be mutated by template rendering) or when ``CPA_COPY_HARDLINK=0``. |
| 36 | + """ |
| 37 | + dest.parent.mkdir(parents=True, exist_ok=True) |
| 38 | + if dest.exists(): |
| 39 | + dest.unlink() |
| 40 | + |
| 41 | + hardlink_ok = allow_hardlink and os.environ.get("CPA_COPY_HARDLINK", "1") != "0" |
| 42 | + |
| 43 | + if os.name != "nt": |
| 44 | + # 1) Reflink / clonefile (near-instant CoW on Btrfs/XFS/ZFS/APFS). |
| 45 | + for args in ( |
| 46 | + ["cp", "-c", "--", str(src), str(dest)], # macOS clonefile |
| 47 | + ["cp", "--reflink=auto", "--", str(src), str(dest)], # GNU |
| 48 | + ): |
| 49 | + try: |
| 50 | + subprocess.run(args, check=True, capture_output=True) |
| 51 | + if dest.is_file(): |
| 52 | + shutil.copystat(src, dest, follow_symlinks=True) |
| 53 | + return "reflink" |
| 54 | + except (FileNotFoundError, subprocess.CalledProcessError, OSError): |
| 55 | + if dest.exists(): |
| 56 | + dest.unlink(missing_ok=True) |
| 57 | + |
| 58 | + # 2) Hardlink (same inode — avoided for rendered/mutated files). |
| 59 | + if hardlink_ok: |
| 60 | + try: |
| 61 | + os.link(src, dest) |
| 62 | + return "hardlink" |
| 63 | + except OSError: |
| 64 | + if dest.exists(): |
| 65 | + dest.unlink(missing_ok=True) |
| 66 | + |
| 67 | + # 3) Full recursive metadata-preserving copy. |
| 68 | + shutil.copy2(src, dest) |
| 69 | + return "copy" |
| 70 | + |
21 | 71 |
|
22 | 72 | def _mode_from_path(rel: Path) -> str: |
23 | 73 | name = rel.name |
@@ -104,8 +154,9 @@ def process_file( |
104 | 154 |
|
105 | 155 | if target.exists() and not overwrite: |
106 | 156 | return None |
107 | | - target.parent.mkdir(parents=True, exist_ok=True) |
108 | | - shutil.copy2(src, target) |
| 157 | + # Plain files: reflink → hardlink → copy2. Never hardlink .template |
| 158 | + # paths (handled above); allow_hardlink stays True for immutable copies. |
| 159 | + copy_file_efficient(src, target, allow_hardlink=True) |
109 | 160 | return target |
110 | 161 |
|
111 | 162 |
|
|
0 commit comments