Изображения вне git: список файлов в репозитории

Изображения — та часть сайта, которую git не переносит. Файл .gitignore обычного вида убирает из репозитория *.jpg, *.jpeg, *.png, *.gif, *.webp, *.svg, *.ico, *.bmp, *.avif, *.tif и *.tiff, поэтому добавленный, заменённый или потерянный файл никак не проявляется в git status, а worktree или контейнер Docker, в котором есть только отслеживаемые файлы, остаётся почти без картинок. Страницы при этом собираются, изображений нет, и тесту, который ищет файл по каждому тегу img, нечего проверять.

Дальше — небольшой скрипт на Python, который держит в репозитории один текстовый файл вместо самих изображений: sha1, размер в байтах и путь каждого файла. Сами изображения в git не попадают: список лишь показывает, когда файл появился, изменился или исчез.

Идея

Один текстовый файл, container/docs/image-files.txt, хранит по строке на изображение: sha1, размер в байтах и путь относительно container/src. Файл достаточно мал, чтобы держать его в репозитории, а изображения остаются вне его. Для этого дерева:

ЧтоСколько
Изображения в container/src19 764 файла, 2,3 ГБ
Папка .git220 МБ
Список2 023 766 байт, 486 561 байт после gzip -9
Первый --write, хэшируется каждый файл120,5 с
--check с тёплым кэшем0,66 с

Список занимает меньше десятой доли процента от байтов, которые описывает, и сотую долю репозитория. Двух чисел на файл достаточно: размер меняется, когда изображение растёт или уменьшается, а sha1 — когда его заменяют другим того же размера и под тем же именем.

Скрипт

Скрипт лежит в container/tools/images/image_list.py, рядом с другими инструментами для изображений. Значения по умолчанию он вычисляет от своего расположения, поэтому запускается из любого каталога репозитория; пути на этой странице указаны от корня. Три режима:

РежимЧто делаетКод возврата
--writeобходит дерево, хэширует что нужно, записывает список0
--checkобходит и сравнивает, ничего не записывает0, если список актуален, 1, если устарел
--hookзаписывает, как --write, ради коммита1, если изменил список, иначе 0

Четыре параметра: --src DIR обходит другое дерево (по умолчанию container/src), --out FILE пишет другой список (по умолчанию container/docs/image-files.txt), --cache FILE хранит кэш в другом месте (по умолчанию container/reports/image-list-cache.tsv), а --no-cache хэширует все файлы и не пишет кэш. Неверный аргумент, например --src, который не каталог, даёт код 2.

Изображением здесь считается обычный файл с расширением jpg, jpeg, png, gif, webp, svg, ico, bmp, avif, tif или tiff в любом регистре; символические ссылки пропускаются. Строки отсортированы по пути в байтовом порядке, поэтому одно и то же дерево всегда даёт один и тот же файл. Весь скрипт:

#!/usr/bin/env python3 # version 0.1.1 """Track the image files under container/src in a committed text file. The images are git-ignored (about 19,800 files, 2.3 GB), so git never shows them and a worktree or the OpenCode container holds almost none. This script writes container/docs/image-files.txt, a small file that is committed instead: # Image files under container/src: sha1, size in bytes, path relative to container/src. # Generated by container/tools/images/image_list.py; do not edit by hand. <sha1> <size> <path> One line per image file on disk, tracked or not, sorted by path in byte order. An image is a regular file whose extension (any letter case) is jpg jpeg png gif webp svg ico bmp avif tif tiff; symlinks are skipped. A path may hold spaces, so a reader splits a line on whitespace at most twice. Usage (any working directory): python3 container/tools/images/image_list.py --write # regenerate the list python3 container/tools/images/image_list.py --check # 1 when the list is stale python3 container/tools/images/image_list.py --hook # pre-commit gate --src DIR scans another tree, --out FILE writes another list, --cache FILE keeps the local sha1 cache (container/reports/, git-ignored). Hashing 2.3 GB on every commit is too slow, so a file whose size and mtime_ns match its cache entry reuses the cached sha1; --no-cache hashes everything. --hook works like --write and exits 1 when the file changed, so the commit stops and the new list can be staged. In a checkout without the images (a worktree, the OpenCode container) fewer than half of the listed paths exist on disk: --hook prints one line saying so and exits 0 without writing. Exit codes: 0 fine, 1 the list changed or is stale, 2 bad arguments. """ from __future__ import annotations import argparse import hashlib import os import sys import tempfile from dataclasses import dataclass from pathlib import Path CONTAINER = Path(__file__).resolve().parents[2] # The defaults: container/src is the tree, container/docs holds the list and # container/reports is git-ignored scratch space for the cache. DEFAULT_SRC = CONTAINER / "src" DEFAULT_OUT = CONTAINER / "docs" / "image-files.txt" DEFAULT_CACHE = CONTAINER / "reports" / "image-list-cache.tsv" # The extensions .gitignore drops, in any letter case. EXTENSIONS = frozenset({"jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp", "avif", "tif", "tiff"}) HEADER = "# Image files under container/src: sha1, size in bytes, path relative to container/src.\n# Generated by container/tools/images/image_list.py; do not edit by hand.\n" MAX_SHOWN = 20 # paths per category in the summary @dataclass(frozen=True) class Image: """What the list records about one file: its sha1 and its size in bytes.""" sha1: str size: int def _is_image_name(name: str) -> bool: """True when a file name ends in one of the image extensions, any case.""" return "." in name and name.rsplit(".", 1)[-1].lower() in EXTENSIONS def scan(src: Path) -> list[tuple[str, int, int, Path]]: """(path relative to src, size, mtime_ns, file) per image, in byte order. os.scandir is used instead of Path.rglob: 20,000 images must stay a second-scale job, and scandir reads the type of an entry from the directory itself. Symlinks (to a file or a folder) are not listed. """ found: list[tuple[str, int, int, Path]] = [] stack = [(str(src), "")] while stack: directory, prefix = stack.pop() with os.scandir(directory) as entries: for entry in entries: rel = prefix + entry.name if entry.is_symlink(): continue if entry.is_dir(follow_symlinks=False): stack.append((entry.path, rel + "/")) elif _is_image_name(entry.name) and entry.is_file(follow_symlinks=False): stat = entry.stat(follow_symlinks=False) found.append((rel, stat.st_size, stat.st_mtime_ns, Path(entry.path))) found.sort(key=lambda item: item[0].encode("utf-8")) return found def sha1_of(path: Path) -> str: """The SHA-1 of a file; file_digest reads in chunks, so a large image stays cheap.""" with path.open("rb") as handle: return hashlib.file_digest(handle, "sha1").hexdigest() CacheKey = tuple[int, int] # size, mtime_ns Cache = dict[str, tuple[int, int, str]] # path relative to src -> size, mtime_ns, sha1 def load_cache(path: Path) -> Cache: """The cache file as a dict; a missing or damaged file is an empty cache.""" cache: Cache = {} if not path.is_file(): return cache for line in path.read_text(encoding="utf-8").splitlines(): if not line or line.startswith("#"): continue fields = line.split("\t") if len(fields) != 4 or not fields[3]: continue try: cache[fields[0]] = (int(fields[1]), int(fields[2]), fields[3]) except ValueError: continue return cache def render_cache(entries: dict[str, Image], stats: dict[str, CacheKey]) -> str: """The cache file text: path, size, mtime_ns, sha1, one file per line.""" lines = ["# path\tsize\tmtime_ns\tsha1\n"] for rel in sorted(entries, key=lambda item: item.encode("utf-8")): size, mtime_ns = stats[rel] lines.append(f"{rel}\t{size}\t{mtime_ns}\t{entries[rel].sha1}\n") return "".join(lines) def collect(src: Path, cache: Cache | None) -> tuple[dict[str, Image], dict[str, CacheKey]]: """Every image under src with its size and sha1, plus the stat keys for the cache.""" entries: dict[str, Image] = {} stats: dict[str, CacheKey] = {} for rel, size, mtime_ns, path in scan(src): stats[rel] = (size, mtime_ns) hit = cache.get(rel) if cache is not None else None sha1 = hit[2] if hit is not None and hit[0] == size and hit[1] == mtime_ns else sha1_of(path) entries[rel] = Image(sha1, size) return entries, stats def render(entries: dict[str, Image]) -> str: """The list file text: the header, then one sha1, size and path per line.""" lines = [HEADER] for rel in sorted(entries, key=lambda item: item.encode("utf-8")): lines.append(f"{entries[rel].sha1} {entries[rel].size} {rel}\n") return "".join(lines) def parse_list(text: str) -> dict[str, Image]: """The entries of a list file text; comments, blank and damaged lines are skipped.""" entries: dict[str, Image] = {} for line in text.splitlines(): if not line or line.startswith("#"): continue fields = line.split(maxsplit=2) # a path may hold spaces if len(fields) != 3: continue try: entries[fields[2]] = Image(fields[0], int(fields[1])) except ValueError: continue return entries def load_list(path: Path) -> dict[str, Image]: """The entries of the list file; a missing file is an empty list.""" return parse_list(path.read_text(encoding="utf-8")) if path.is_file() else {} def relate(old: dict[str, Image], new: dict[str, Image]) -> tuple[list[str], list[str], list[str]]: """(added, removed, changed) paths, sorted, between the list and the disk.""" added = sorted(new.keys() - old.keys(), key=lambda item: item.encode("utf-8")) removed = sorted(old.keys() - new.keys(), key=lambda item: item.encode("utf-8")) changed = sorted((rel for rel in new.keys() & old.keys() if new[rel] != old[rel]), key=lambda item: item.encode("utf-8")) return added, removed, changed def _show(label: str, paths: list[str]) -> None: """One summary line: the first MAX_SHOWN paths of a category.""" more = f", ... ({len(paths) - MAX_SHOWN} more)" if len(paths) > MAX_SHOWN else "" print(f" {label}: {', '.join(paths[:MAX_SHOWN])}{more}") def report(out: Path, entries: dict[str, Image], differs: bool, old: dict[str, Image]) -> None: """Print the summary: the file count, then what changed and, in short, where.""" print(f"{out}: {len(entries)} image(s) on disk") if not differs: print(" unchanged") return added, removed, changed = relate(old, entries) print(f" {len(added)} added, {len(removed)} removed, {len(changed)} changed") for label, paths in (("added", added), ("removed", removed), ("changed", changed)): if paths: _show(label, paths) if not (added or removed or changed): print(" the text differs only in order or header") def write_atomic(path: Path, text: str) -> None: """Write `text` to a temp file next to `path`, then rename it into place.""" path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) os.chmod(tmp, 0o664) # mkstemp creates 0600 os.replace(tmp, path) finally: Path(tmp).unlink(missing_ok=True) # gone already when the rename worked def lacks_images(out: Path, src: Path) -> tuple[bool, str]: """True when this checkout lacks the images. The list names the files of a full checkout. When fewer than half of them exist here (a worktree, the OpenCode container), regenerating the list would drop the rest, so --hook leaves it alone. """ listed = load_list(out) if not listed: return False, "" present = sum(1 for rel in listed if (src / rel).is_file()) if present * 2 >= len(listed): return False, "" return True, (f"{out}: this checkout holds {present} of {len(listed)} listed images, so it has no images; the list is left alone") def _parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="image_list.py", description="Write or check the tracked list of the image files under container/src.", ) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--write", action="store_true", help="regenerate the list") mode.add_argument("--check", action="store_true", help="exit 1 when the list is stale, never write") mode.add_argument("--hook", action="store_true", help="pre-commit: write, exit 1 when the file changed") parser.add_argument("--src", type=Path, default=DEFAULT_SRC, metavar="DIR", help="scanned tree (default: container/src)") parser.add_argument("--out", type=Path, default=DEFAULT_OUT, metavar="FILE", help="list file (default: container/docs/image-files.txt)") parser.add_argument("--cache", type=Path, default=DEFAULT_CACHE, metavar="FILE", help="sha1 cache (default: container/reports/image-list-cache.tsv)") parser.add_argument("--no-cache", action="store_true", help="hash every file, ignore and keep no cache") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = _parse_args(sys.argv[1:] if argv is None else argv) src: Path = args.src out: Path = args.out if not src.is_dir(): print(f"error: {src} is not a directory", file=sys.stderr) return 2 if args.hook: lacks, message = lacks_images(out, src) if lacks: print(message) return 0 cache_path: Path | None = None if args.no_cache else args.cache cache = load_cache(cache_path) if cache_path is not None else None entries, stats = collect(src, cache) text = render(entries) differs = not out.is_file() or out.read_text(encoding="utf-8") != text report(out, entries, differs, load_list(out)) if args.check: if differs: print("--check: the list is stale, run --write to regenerate it") return 1 return 0 if differs: write_atomic(out, text) print(f"wrote {out}") if cache_path is not None: write_atomic(cache_path, render_cache(entries, stats)) return 1 if args.hook and differs else 0 if __name__ == "__main__": sys.exit(main())

Первый запуск

В демонстрационном репозитории этой страницы два изображения, img/logo.png и img/photo.jpg, а списка пока нет. Первый запуск хэширует всё и записывает его:

python3.12 container/tools/images/image_list.py --write

/home/me/site/container/docs/image-files.txt: 2 image(s) on disk 2 added, 0 removed, 0 changed added: img/logo.png, img/photo.jpg wrote /home/me/site/container/docs/image-files.txt

Первая строка считает изображения на диске, вторая сравнивает новый список со старым, третья перечисляет первые двадцать путей каждого вида и дальше добавляет , ... (N more). На настоящем дереве первая строка говорит 19764 image(s) on disk, а первый запуск занимает около двух минут. Вот что он записал:

cat container/docs/image-files.txt

# Image files under container/src: sha1, size in bytes, path relative to container/src. # Generated by container/tools/images/image_list.py; do not edit by hand. 9c71545958fd6fde8eb0ae49e8f056fdbfcbd411 2048 img/logo.png ec43fa5da56b5e4684d9046320dcafd29766c3ac 4096 img/photo.jpg

Две строки комментариев, затем по строке на файл: sha1 из 40 символов, два пробела, размер в байтах, два пробела, путь. В пути может быть пробел, поэтому строку читают, разделяя не больше двух раз. Список, совпадающий с диском, — это то, чего ждёт --check: unchanged и код 0; в разделе про хук это видно на дереве с тремя изображениями.

Хук pre-commit

Список полезен, только пока он не старше изображений. Хук pre-commit из фреймворка pre-commit — это одна запись в .pre-commit-config.yaml в корне репозитория; pre-commit install один раз на клон пишет файл хука:

vi .pre-commit-config.yaml

- repo: local hooks: - id: image-list name: image list (container/docs/image-files.txt) entry: python3.12 container/tools/images/image_list.py --hook language: system pass_filenames: false always_run: true

Две строки здесь главные: pass_filenames: false и always_run: true. Среди подготовленных к коммиту файлов изображений почти не бывает: git их игнорирует, исключение — имена в верхнем регистре из последнего раздела, поэтому хук, который вызывается только для изменённых файлов, почти никогда бы не запускался. Этот запускается на каждом коммите и смотрит на диск. Второй локальный хук, image-list-tests, запускает только тесты скрипта, pytest container/tests/tools/test_image_list.py -q, и только когда меняется container/tools/images/image_list.py. Пути к изображениям он не проверяет.

Пока список совпадает с диском, хук проходит:

git commit -m "First commit"

image list (container/docs/image-files.txt)..............................Passed

Теперь добавляют изображение и страницу с ним. Изображение игнорируется, поэтому к коммиту подготовлена только страница:

git status --short

M container/src/index.html

git commit -m "Adds the banner"

image list (container/docs/image-files.txt)..............................Failed - hook id: image-list - exit code: 1 - files were modified by this hook /home/me/site/container/docs/image-files.txt: 3 image(s) on disk 1 added, 0 removed, 0 changed added: img/banner.png wrote /home/me/site/container/docs/image-files.txt

Коммит остановился, и это поведение форматтера, а не ошибка скрипта. Хук выполнил --write, список на диске изменился, а хук, который меняет файл, завершает прогон с files were modified by this hook: изменение нужно увидеть и добавить в индекс, иначе оно проникнет в историю незаметно, в коммите, в сообщении которого о нём ничего нет. По тому же правилу останавливается и посторонний коммит, пока список устарел: хук запускается на каждом коммите, а устаревший список означает, что какого-то изображения в нём уже нет.

Хук оставил список изменённым на диске, но в индекс его не добавил:

git status --short

M container/docs/image-files.txt M container/src/index.html

Список отсортирован по пути, поэтому img/banner.png стоит первым, перед img/logo.png и img/photo.jpg; последние строки diff:

git diff -- container/docs/image-files.txt

+ba36c6c91209c9d7969297162a6ade0486ca9f96 3000 img/banner.png 9c71545958fd6fde8eb0ae49e8f056fdbfcbd411 2048 img/logo.png ec43fa5da56b5e4684d9046320dcafd29766c3ac 4096 img/photo.jpg

Добавить список в индекс и закоммитить снова — вот и всё исправление:

git add container/docs/image-files.txt

git commit -m "Adds the banner"

image list (container/docs/image-files.txt)..............................Passed

Последние строки лога:

git log --oneline --stat -1

container/docs/image-files.txt | 1 + container/src/index.html | 1 + 2 files changed, 2 insertions(+)

Коммит несёт два файла: страницу и одну строку текста. Самого баннера в git по-прежнему нет, а строка доказывает, что он был. На этом дереве проверка согласна:

python3.12 container/tools/images/image_list.py --check

/home/me/site/container/docs/image-files.txt: 3 image(s) on disk unchanged

Код возврата 0, значит следующий коммит хук пройдёт.

Кэш

Хэшировать 2,3 ГБ на каждом коммите слишком долго: первый --write занимает 120,5 секунды, а хук, который стоит две минуты, отключают. Скрипт хранит sha1 прошлого запуска в container/reports/image-list-cache.tsv — файле, который git игнорирует, — и доверяет ему, пока ключ записи совпадает с файлом на диске. Ключ — это путь и два числа: размер и время изменения в наносекундах. Если совпали оба, sha1 берётся из кэша и файл не читается; если хотя бы одно другое, файл хэшируется заново и запись перезаписывается. С тёплым кэшем --check занимает 0,66 секунды.

--no-cache хэширует все файлы и не пишет кэш. Он нужен после восстановления из резервной копии, которая может сохранить размеры и время изменения, или когда кэш вызывает сомнения. Один случай кэш не замечает по своему устройству: файл, заменённый таким же по размеру, у которого время изменения вернули к прежнему значению, хранит старый sha1, пока его снова не прохэшируют. --no-cache и есть это «снова». Кэш в репозиторий не попадает, список попадает.

Worktree и контейнеры

В дополнительном рабочем дереве (worktree) есть отслеживаемые файлы и почти нет изображений. Создадим его, перейдём туда и запустим хук:

git worktree add ../site-wt -b wt

cd ../site-wt

python3.12 container/tools/images/image_list.py --hook

/home/me/site-wt/container/docs/image-files.txt: this checkout holds 0 of 3 listed images, so it has no images; the list is left alone

На диске меньше половины перечисленных путей, поэтому хук печатает одну строку и выходит с кодом 0, ничего не записывая. Если пересобрать список здесь, из него пропадут все 3 перечисленных пути; в worktree настоящего дерева, где есть 242 пути из 19 764, пропадут 19 522. В обоих случаях коммит унёс бы изуродованный список, поэтому хук файл не трогает, и коммит проходит без изменений:

git commit -am "Edits the page in a worktree"

image list (container/docs/image-files.txt)..............................Passed

Тот же приём выручает тест путей в тегах img. В дереве без изображений ему больше не нужен корень с картинками: он читает container/docs/image-files.txt и считает существующим путь, который назван в списке. Без списка тест сообщает о 4 048 отсутствующих файлах, со списком — о 243, тех же 243, что и полное дерево с настоящими изображениями. Список говорит, что файл где-то есть, а не что в нём нужная картинка.

Варианты и ограничения

Список хранит имена, размеры и sha1, но не содержимое. Замену файла таким же по размеру он ловит — ради этого и выбраны два числа. img/photo.jpg, заменённый другим файлом на 4 096 байт:

python3.12 container/tools/images/image_list.py --check

/home/me/site/container/docs/image-files.txt: 3 image(s) on disk 0 added, 0 removed, 1 changed changed: img/photo.jpg --check: the list is stale, run --write to regenerate it

Код возврата 1 — то, что нужно задаче CI или хуку.

Автор статьи: Arthur Isaev