Если вы работаете в стиле «одна задача — свой worktree», у вас наверняка скопилась папка, где рядом лежат полдюжины чекаутов одного проекта. Эта история про то, как я устал бегать по ним с git status и написал крошечную утилиту lsw — «ls» для worktree. Одна команда показывает все репозитории и worktree вокруг: ветку каждого, есть ли незакоммиченные правки и насколько ветка ушла от origin. Read‑only, ноль зависимостей, один бинарник. Под катом — зачем она нужна, как выглядит и что у неё внутри.

Откуда это взялось

Для тех, кто не пользуется: git worktree add ../webapp-1042 feature/TASK-1042 создаёт второй рабочий каталог того же репозитория. Две ветки чекнуты одновременно, никаких стэшей и git checkout‑каруселей: в одной папке вы чините прод, в другой спокойно пишете фичу. Недооценённая штука.

Я веду почти всю разработку в этом режиме: каждая задача — свой каталог. Через месяц рядом с проектом скапливается такое хозяйство:

webapp/            ← main, главный клон
webapp-1042/       ← фича TASK-1042, в ней сейчас работаю
webapp-hotfix/     ← а тут висят незакоммиченные правки
webapp-review/     ← ревью чужого PR
docs/

И каждый раз, возвращаясь к проекту, один и тот же ритуал:

  • в какой из папок я остановился вчера?

  • где лежат незакоммиченные правки?

  • какая ветка уехала вперёд от origin, а какая отстала?

  • а эта remote‑ветка вообще ещё жива?

Руками это cd × N плюс git status в каждой папке. С двумя каталогами терпимо, с пятью — уже ритуал. git worktree list тут помогает лишь частично: он работает только внутри одного репозитория и не показывает ни dirty‑состояние, ни ahead/behind.

Лёгкой утилиты, которая отвечала бы на всё это одной командой по всей папке проектов, мне не попалось — так и появился lsw.

Что показывает lsw

Запускаю lsw в папке с проектами:

lsw — древовидный вывод
lsw — древовидный вывод

Логика простая: внутри репозитория lsw показывает его worktree, внутри обычной папки — сканирует репозитории на уровень вглубь (-2 и -3 — глубже). Для каждого worktree — ветка, грязность и дистанция до upstream.

Как читать вывод:

Токен

Значение

./repo · host/user/repo

заголовок группы: путь и origin в компактном виде (если origin есть)

· .wt/имя

каталог linked worktree — так вложенный чекаут отличается в своей группе

* (зелёный)

worktree, в котором вы сейчас находитесь

N dirty

незакоммиченные изменения (жёлтым)

clean

изменений нет (зелёным)

↑N

ветка впереди upstream на N коммитов (циан)

↓N

позади upstream на N (красный)

no upstream

у ветки нет remote tracking

upstream gone

remote‑ветка удалена

bare

bare‑репозиторий

Флаг -b переключает на карточки: ветка, статус и remote tracking branch в каждом блоке:

lsw -b — блочный вывод
lsw ‑b — блочный вывод

А -bp расписывает грязное состояние пофайлово — с +добавлено/−удалено и статусом каждого файла — и добавляет строку last с возрастом и темой последнего коммита:

lsw -bp — пофайловая детализация
lsw ‑bp — пофайловая детализация

Есть в worktree‑хозяйстве и тихая беда: симлинки на внешнее. Внешняя библиотека, примонтированные данные, локальные секреты — всё это живёт линками, ведущими за пределы репозитория, и когда цель исчезает (mount не поднят, путь переехал), git status молчит: для него symlink — просто маленький файл. С v0.1.5 флаг -s проверяет верхний уровень корня каждого worktree и красит битые красным токеном N broken links:

lsw -s — токены битых симлинков
lsw ‑s — токены битых симлинков

А -bps показывает, куда именно каждый битый линк указывает — и сразу видно, что чинить:

lsw -bps — список битых симлинков в карточках
lsw ‑bps — список битых симлинков в карточках

Чего в lsw сознательно нет

Четыре принципа, вокруг которых всё построено:

  1. Read‑only. Никакого состояния, конфигов и демона. lsw никогда не пишет в ваши репозитории — только читает.

  2. Ноль зависимостей. Rust std и тот самый git, который у вас уже стоит. Ни libgit2, ни OpenSSL — нечего аудитить. Один статический бинарник ~1 МБ.

  3. Быстро. Весь ввод‑вывод — один git worktree list на репозиторий и один git status на worktree. Дальше только разбор текста.

  4. Одна функция. lsw только показывает. Создание, удаление, prune — за бортом осознанно: git worktree сам отлично умеет управлять worktree, дублировать его не нужно.

Всё это хорошо ложится на философию omarchy, под которой я его и писал: маленькие острые инструменты, которые сочетаются друг с другом. Цветной вывод — только в TTY; в пайпе текст чистый, так что lsw | grep dirty и lsw | fzf работают как ожидается. NO_COLOR и NO_ICONS уважаются, а без Nerd Font иконки деградируют в ASCII (или --no-icons принудительно).

Под капотом

Проект на чистом Rust: около двух тысяч строк вместе с тестами, ноль сторонних крейтов, 53 теста. Сборке нужен только Rust 1.70+ и системный git.

Скан каталогов

Обход идёт по уровням (по умолчанию один, -2/-3 — глубже); скрытые каталоги, node_modules и target не спускаются. Узлы различаются по .git: каталог с .git/ внутри — репозиторий, .git‑файл — linked worktree (именно так git оформляет связанные чекауты: файл с gitdir:‑ссылкой). На каждый найденный репозиторий — один git worktree list, который сразу отдаёт все его worktree, включая bare.

Дальше — самое интересное. Worktree, вложенные внутрь каталога репозитория (классическая раскладка repo/{main,wt1,wt2}), попадают под его заголовок и не дублируются. Worktree‑сиблинги, лежащие рядом с репозиторием, получают собственные группы.

История одного бага

Разделить эти два случая с первого раза у меня не получилось. До v0.1.3 дедуп в скане выкидывал worktree, чей «настоящий» корень уже встречался в списке. Для вложенных worktree логика «уже показан этим репозиторием» работала, но сиблинг, встретившийся после своего репозитория по алфавиту, тоже выкидывался — а рендерил его только репозиторий, в чьё поддерево сиблинг не попадал. Итог: две папки просто исчезали из выдачи. Причём результат зависел от порядка обхода: another_tasks (a < c) выживал, а supports и support_tasks (s > c) — нет.

Починилось одной строкой условия: дедуп применяем только к worktree, вложенным внутрь каталога уже показанного репозитория. Делал по TDD: сначала два падающих теста — репро реальной раскладки и проверка независимости от порядка сортировки, потом фикс, потом все 53 зелёные. Мораль: докстринги врут даже в коде на несколько сотен строк.

Состояние и деградации

Dirty‑счётчик и ↑/↓ берутся из одного git status на worktree; -bp разворачивает полный список файлов. Проверка симлинков (-s) — это чистый std::fs: один проход read_dir по верхнему уровню корня, symlink_metadata плюс контрольный metadata для самих линков, ни одного лишнего вызова git — дефолтный запрос остаётся таким же быстрым. Stale‑worktree, у которых .git указывает на исчезнувший репозиторий, пропускаются молча, а --debug покажет на stderr, что и почему пропущено. Когда ничего не найдено, lsw не молчит, а печатает короткую заметку: где искал, на какой глубине, сколько каталогов было нечитаемо — и выходит с кодом 0.

Установка

# инсталлятор: prebuilt-бинарник последнего релиза, x86_64-linux
curl -fsSL https://raw.githubusercontent.com/apilot/lsw/main/install.sh | sh
# или сборка из исходников (любая платформа с Rust и git)
cargo install --git https://github.com/apilot/lsw

Скрипт проверяет sha256 скачанного бинарника и кладёт его в ~/.local/bin (перезадаётся через LSW_PREFIX). Работает на любой ОС, где в PATH есть git.

Вместо заключения

Утилита маленькая и сознательно ограниченная: она не управляет worktree, а делает видимым то, что git уже знает. Мне она заменила утренний обход папок одной командой — вдруг и вам пригодится.

GitHub: apilot/lsw — issue и PR приветствуются. Особенно интересны раскладки worktree‑хозяйства, которые скан обрабатывает неправильно: таких layout’ов столько, сколько у людей привычек. AUR‑пакет уже готов — ждал открытия регистрации AUR (её закрывали на наплыв ботов) и уйдёт в репозиторий, как только откроют.

Комментарии (3)


  1. dsignmark
    19.10.2026 10:22

    у нас worktree по задаче на каждого исполнителя, их бывает штук по двадцать на один репо, так что боль знакома) от себя бы добавил одну вещь которой очень нехватало

    node_modules мы не ставим в каждое дерево, а симлинкаем из одного, иначе диск кончается за неделю. и вот однажды снесли "старое чистое" дерево, а на него смотрели симлинки еще из трех. в тех трех vitest потом честно отрапортовал зелёным, потому что не нашел ни одного теста. заметили не сразу

    так что для удаления мне бы в lsw хотелось кроме dirty и ahead/behind видеть еще кто ссылается на это дерево снаружи. ну и размер, чтобы понимать что сносить первым

    а ветки которые уже влиты в main он как то помечает? у нас основной мусор как раз такие


    1. aboyarinov Автор
      19.10.2026 10:22

      благодарю за отзыв. напишу в ЛС по подробностям


    1. aboyarinov Автор
      19.10.2026 10:22

      добавил функционал, интересный кейс. благодарю за идею. буду рад отзыву по его работе