Каждый проект оставляет мусор: почему разрастается диск разработчика?
TL;DR: после каждого проекта на диске остаётся 2–40 ГБ мусора, который никто никогда не убирает. Я раскопал свой Mac (460 ГБ, из них свободно было 14), нашёл около 150 ГБ хлама чисто девелоперского происхождения, по дороге упёрся в неожиданно интересную задачу — как отличить папку build со сборкой от папки build с чем угодно ещё — и в итоге написал себе инструмент. Ниже — вся археология с цифрами и командами, можно повторить руками, ничего не покупая и не скачивая.
Диагноз
Началось банально: macOS показала «Your disk is almost full». Смотрю — 14 ГБ свободно из 460. При этом фильмов нет, фототека в облаке, торренты не качаю. Классическая ситуация «я ничего не делал, оно само».
Первая мысль была классическая: удалить пару файлов из Downloads, перезагрузить Mac и надеяться на чудо. Но я решил не заниматься шаманством, а разобраться, кто на самом деле съел диск. Оказалось — разработка.
$ du -h -d 1 ~ 2>/dev/null | sort -hr | head -15257G /Users/banana 87G /Users/banana/Library 78G /Users/banana/Projects 53G /Users/banana/Parallels 10G /Users/digkill/.espressif8.1G /Users/banana/.gemini3.4G /Users/banana/go2.2G /Users/banana/.rustup2.0G /Users/banana/.gradle1.9G /Users/banana/.cursor
Уже интересно. .espressif занимал 10 ГБ — это тулчейны ESP-IDF, оставшиеся с тех времён, когда я «на пять минут» решил помигать светодиодом на ESP32. Эксперимент давно закончился, а десять гигабайт остались жить на диске. Рядом — .rustup: ещё два гигабайта компиляторов ради пары туториалов. И это была только верхушка айсберга.
$ du -h -d 1 ~/Library 2>/dev/null | sort -hr | head -8 87G /Users/banana/Library 29G /Users/banana/Library/Application Support 24G /Users/banana/Library/Caches 14G /Users/banana/Library/Arduino15 10G /Users/banana/Library/Developer
Кэши тоже не подвели — 24 ГБ. Yarn честно накопил 5.1 ГБ, Google — ещё 4.3. Но больше всего меня впечатлила утилита для записи SD-карт: использовал её один раз, а она решила, что 4.1 ГБ образов операционных систем мне ещё обязательно пригодятся.
Arduino15 добавил к этой коллекции ещё 14 ГБ. Если сложить всё вместе, получается, что один мигающий светодиод на ESP32 стоил моему диску около 25 гигабайт. Дороговатый светодиод.
Профессиональная болезнь
В какой-то момент я понял, что проблема вообще не в моём компьютере. Это особенность профессии.
У разработки есть странное свойство: у неё нет этапа уборки. В жизненном цикле проекта есть планирование, код, ревью, тесты, сборка, деплой, мониторинг и даже вывод из эксплуатации. Но нигде нет шага: «проект закончен — удали всё, что он притащил с собой».
Ни один CI этого не делает. Ни одна методология этого не требует. Scrum про это вообще молчит.
Поэтому каждый проект, даже написанный за один вечер, оставляет после себя небольшой археологический слой:
-
зависимости (
node_modules,vendor,Pods); -
артефакты сборки (
target,build,dist,DerivedData); -
глобальные кэши пакетных менеджеров;
-
SDK, тулчейны и рантаймы, установленные когда-то «на попробовать».
Пока проект живёт, всё это оправдано. Но проект заканчивается, а его инфраструктура остаётся. Причём часто навсегда.
Через несколько лет рабочая машина превращается в цифровое кладбище давно закрытых проектов. Код удалён, репозиторий заархивирован, заказчик уже забыл, что такой проект вообще существовал, а десятки гигабайт зависимостей и SDK продолжают спокойно занимать место на диске.
Мне стало интересно, насколько это запущено у меня. Я прошёлся по ~/Projects и посчитал.
$ find ~/Projects -type d -name node_modules -prune | wc -l
Двадцать один node_modules. Живых проектов из них — от силы три. Остальные восемнадцать давно превратились в цифровые руины, но продолжают хранить по 100–800 МБ зависимостей каждый.
В сумме только в ~/Projects накопилось 45 ГБ сборочных артефактов и ещё 8 ГБ node_modules. Почти ещё один SSD.
Отдельный привет пакетным менеджерам. Кэш Go-модулей (~/go/pkg/mod) занимал ещё 3.2 ГБ — библиотеки для проектов, которые давно сданы и забыты. Если когда-нибудь снова понадобятся, они скачаются за несколько минут. Скорее всего, никогда не понадобятся.
Первая итерация: shell-скрипт, конечно же
Как любой разработчик, я первым делом написал shell-скрипт. rm -rf по списку каталогов, brew cleanup, npm cache clean --force — классический набор.
Скрипт честно отработал и освободил около 60 ГБ. Я был доволен. Через три недели диск снова оказался почти полным. Кэши на то и кэши.
А скрипт я запускать боялся. Серьёзно: открываешь его через месяц, смотришь на строку rm -rf ~/Library/Caches/* и думаешь — а я точно помню, почему тут звёздочка и что будет с приложениями, которые сейчас запущены? Скрипт, который страшно запускать, — это не автоматизация. Это заряженное ружьё, которое лежит в ящике стола.
И главная проблема даже не в страхе. Скрипт статичен: он чистит те пути, которые я вписал в него в момент написания. А новый мусор появляется постоянно — поставил новый редактор, он завёл свой кэш; попробовал новый фреймворк — привет, новая папка в ~/Library.
Внезапно интересная задача: build против build
И тут выяснилось, что самая интересная часть проекта вообще не связана с очисткой диска.
Она оказалась связана с гораздо более неприятным вопросом: как понять, что именно можно удалять?
Сначала всё выглядело просто. Хочется найти на диске все сборочные артефакты, где бы они ни лежали. Не только в ~/Projects: кто-то хранит проекты в ~/Documents, кто-то в ~/work, кто-то вообще на рабочем столе.
С node_modules, .venv и pycache проблем нет. Эти имена почти невозможно использовать для чего-то другого. Нашёл — можно смело предлагать удалить.
Но очень быстро начинаются серые зоны.
-
target— это сборка Rust или папка «целевая аудитория» у маркетологов? -
build— каталог Gradle, CMake или Flutter? Или дизайнер так назвал папку с финальными макетами? -
dist— результат сборки Webpack или архив с дистрибутивами? -
vendor— зависимости Composer?go mod vendor? Или документы от поставщиков? -
Удалять только по имени — гарантированно однажды удалить что-нибудь важное.
-
Не удалять вовсе — оставить на диске десятки гигабайт мусора.
Получается, одного имени папки недостаточно. Нужен способ понять её контекст.
И тут я заметил интересную закономерность.
Практически каждая экосистема оставляет рядом с собой характерные «следы». Rust почти всегда живёт рядом с Cargo.toml. Gradle — с build.gradle. Flutter — с pubspec.yaml. CMake — с CMakeLists.txt. Python — с pyproject.toml или setup.py. У каждой технологии есть свои отпечатки пальцев.
Значит, искать нужно не просто папку с подходящим названием, а совпадение нескольких признаков.
В итоге получилось очень простое правило: папка считается сборочным артефактом только тогда, когда рядом обнаруживается файл-маркер, подтверждающий, что она действительно относится к конкретной экосистеме.
Например:
|
Папка |
Считается сборкой только если рядом есть |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Эта эвристика оказалась удивительно надёжной. Она не пытается угадать по названию, а проверяет окружение. Благодаря этому вероятность случайно предложить удалить что-то ценное становится на порядок ниже, а количество найденного мусора практически не уменьшается.
Именно в этот момент я понял, что пишу уже не очередной cleaner, а небольшой детектор цифровых артефактов разработчика. Это оказалось гораздо интереснее, чем просто удалить пару десятков гигабайт.
С .venv получилось особенно красиво. Python сам оставляет внутри виртуального окружения файл pyvenv.cfg, поэтому такая папка буквально предъявляет паспорт — дополнительных эвристик не нужно.
С bin и obj в .NET оказалось интереснее. Там маркер — не конкретный файл, а наличие рядом проекта (*.csproj или *.fsproj). Значит, приходится смотреть не внутрь самой папки, а в родительский каталог. На больших дисках быстро выясняется, что постоянное чтение одной и той же директории — дорого, поэтому листинги пришлось кэшировать.
Конечно, эвристика не идеальна. Теоретически можно представить папку target рядом с Cargo.toml, в которой лежит что-то ценное. Практически — cargo сам создаёт и пересоздаёт эту директорию, а если кто-то решил хранить важные файлы внутри каталога сборки, то это уже весьма смелое архитектурное решение. Да и в любом случае последнее слово остаётся за пользователем — приложение лишь предлагает кандидатов, а не удаляет их автоматически.
Оставалась ещё одна проблема — скорость.
Наивный обход всего домашнего каталога быстро превращается в экскурсию по гигабайтам, которые вообще не имеют отношения к задаче. Поэтому пришлось добавить несколько простых правил.
-
Не заходить в
~/Library,~/Pictures,~/Musicи верхнеуровневые скрытые каталоги (~/.npm,~/.gradleи т. п.). Их содержимое анализируется отдельно, иначе получится двойной учёт. -
Не заходить в
.git— внутри объектов репозитория искать нечего. -
Не обходить найденные каталоги повторно. Например, если уже найден
.venv, то искать внутри негоpycacheбессмысленно. -
Ограничить глубину обхода. Некоторые монорепозитории способны превратить рекурсивный поиск в отдельный стресс-тест для SSD.
После этих ограничений сканирование перестало быть узким местом. Поиск кандидатов занимает всего несколько секунд даже на моём захламлённом диске. Больше времени уходит не на поиск, а на подсчёт их реального размера. Это, как ни странно, оказалось самой дорогой операцией во всём процессе.
Грабли, о которых заранее не знаешь
Есть несколько вещей, о которые я споткнулся уже в процессе. Возможно, кому-то это сэкономит пару вечеров.
Go-модули не хотят удаляться
Кэш Go-модулей защищён от удаления. go mod раскладывает пакеты с правами 0555, поэтому обычный rm -rf (или FileManager.removeItem) благополучно падает где-то на середине процесса.
Лечится просто: перед удалением рекурсивный chmod u+w.
Пока не знаешь этой особенности, выглядит как магия: удалил несколько гигабайт — освободилось пару сотен мегабайт.
Размер файла — не тот, который показывает Finder
Довольно быстро выяснилось, что «размер файла» — понятие условное.
Если считать логический размер, цифры почти никогда не совпадают с тем, сколько места реально освободится. APFS умеет хранить разрежённые файлы, клоны (Copy-on-Write), сжатые данные.
Поэтому считать пришлось не размер файла, а allocated size — количество реально занятых блоков.
Именно это число отвечает на вопрос пользователя: «Сколько гигабайт я действительно освобожу?»
APFS не спешит отдавать место
Удаляешь 20 ГБ.
Запускаешь df.
Свободного места столько же.
Первые несколько секунд кажется, что где-то ошибка.
Потом место постепенно появляется само.
Особенно забавно с Корзиной. Пока пользователь её не очистит, место вообще не освободится. Это абсолютно правильное поведение файловой системы, но если приложение не укажет на это — выглядит как баг.
Что получилось в итоге
После всего этого стало понятно, что проблема вовсе не в отсутствии команд очистки. Не хватает инструмента, которому можно доверять. Мне хотелось решить всего три задачи.
1. Сначала показать, потом удалять
Не информативное сообщение:
Найдено мусора: 63 ГБ
а нормальный список.
-
Вот категория.
-
Вот конкретные папки.
-
Вот их размер.
-
Вот галочки.
2. Показывать степень риска
Не весь мусор одинаковый:
-
Кэш логов — безопаснро
-
Кэши пакетных менеджеров — безопасно, но потом придётся заново скачать зависимости.
-
Бэкапы iPhone, Downloads или старые архивы — уже потенциально важные данные.
Поэтому всё разделено по уровням риска.
3. Никогда не удалять без возможности передумать
По умолчанию всё отправляется в Корзину.
Несколько гигабайт стоят дешевле, чем одна случайно удалённая нужная папка.
В итоге получился Again Cleaner.
Небольшое нативное приложение на SwiftUI, которое делает всё описанное выше.
Оно умеет искать стандартные категории мусора, автоматически находить хвосты давно удалённых приложений, искать сборочные артефакты по эвристике с маркерами и показывать результат до удаления.
Если вам комфортнее работать в терминале — отлично. Почти всё из этой статьи можно сделать руками стандартными командами.
Если нужен визуальный интерфейс, есть хорошие инструменты вроде ncdu, DaisyDisk и mac-cleanup-py.
Again Cleaner не пытается заменить их все. Он просто специализируется на том типе мусора, который годами копится именно у разработчиков.
Из ближайших планов — история изменений.
Хочется не просто показать текущее состояние диска, а видеть, как оно меняется со временем. Чтобы приложение информировало:
«За последний месяц у тебя снова выросло 18 ГБ сборочного мусора.»
Вместо выводов
Писать код мы научились. Писать тесты тоже. Автоматизировать деплой — тем более. А вот убирать за своими проектами индустрия почему-то так и не научилась.
После каждого проекта остаётся маленький цифровой археологический слой: зависимости, SDK, кэши, сборки, тулчейны.
-
Проект давно закрыт.
-
Репозиторий заархивирован.
-
Заказчик уже не помнит, что он вообще существовал.
А его инфраструктура продолжает занимать десятки гигабайт.
Пока универсального post-project-mortem для файловой системы человечество не придумало, остаётся хотя бы иногда заглядывать в свой домашний каталог.
Запустите:
du -h -d 1 ~ | sort -hr | head -20
Я думаю, что размер ~/Library вас неприятно удивит.
Какой самый неожиданный пожиратель места обнаружили вы?
Пишите в комментариях. Я собираю такие находки в отдельную коллекцию и самые интересные категории постепенно добавляю в сканер. Пока мой личный рекорд — 17-гигабайтный runtime iOS Simulator, который вообще не отображался ни в одном du.
Ссылка на приложение: https://againcleaner.com
ссылка на оригинал статьи https://habr.com/ru/articles/1062844/