Ежедневная работа: staging, коммиты, diff, stash, отмена изменений
В предыдущей статье мы разобрали объектную модель: blob, tree, commit, tag, ссылки и DAG истории. Это фундамент, но в него нельзя ткнуть пальцем — вы не работаете с блобами напрямую. Ежедневно вы работаете с шестью-семью командами: status, add, commit, diff, stash, restore, log. И именно здесь люди застревают на годы, потому что учат команды как заклинания: «чтобы отменить — вот эта строчка, её мне дал коллега».
Заклинания ломаются в первой же нестандартной ситуации. Модель — нет. Поэтому статья устроена так: сначала вводим третье дерево, о котором в статье про объекты не было ни слова, — индекс, потом смотрим на его реальный бинарный формат, а дальше каждая команда становится очевидной, потому что превращается в вопрос «какое дерево во что копируем». Обзорную статью «Git» в треке development (https://courses.digitable.life/post/development/git/) и статью «Pull Request» я при этом не пересказываю — там про философию распределённых VCS и про оформление PR, здесь механика вашего локального дня.
Третье дерево: индекс
В объектной модели есть коммиты, которые ссылаются на деревья. Между вашим редактором и коммитом стоит промежуточная зона, у которой три названия: index, staging area и cache (все три встречаются в документации и в именах команд — git update-index, git diff --cached, «staged changes»). Это не три сущности, а один файл .git/index.
Зачем он вообще нужен? В Mercurial и SVN его нет: вы правите файлы и коммитите. Git добавляет явный шаг, и это осознанное решение, дающее две вещи. Первая: коммит — это не «состояние диска», а собранное вами утверждение; можно внести пять правок, а закоммитить две, и получить историю, где каждый коммит решает ровно одну задачу. Вторая: производительность — индекс кэширует результаты stat(), поэтому git status в репозитории с 50 000 файлов не читает 50 000 файлов.
Ключ ко всему дальнейшему: git status и git diff не «показывают изменения». Они сравнивают пары деревьев. «Changes to be committed» — это diff между HEAD и индексом. «Changes not staged for commit» — diff между индексом и рабочим каталогом. Untracked-файлы — то, чего нет в индексе вообще.
Отсюда сразу следует ответ на классический вопрос новичка «я сделал git add, а потом ещё правку — что попадёт в коммит?». Попадёт то, что лежит в индексе, то есть версия на момент add. Файл при этом окажется одновременно в обоих списках git status — и это не баг, а прямое следствие модели.
Что физически лежит в .git/index
Формат описан в документации: заголовок DIRC (dircache), версия, число записей, дальше отсортированные по пути записи, дальше расширения, в конце контрольная сумма. Одна запись — это ctime (8 байт) | mtime (8) | dev (4) | ino (4) | mode (4) | uid (4) | gid (4) | size (4) | SHA-1 объекта (20) | флаги (2) | путь. Посмотреть это можно фарфоровыми командами:
$ git ls-files --stage
100644 8b9e21f4a3ca0d18c2a1b7f2e5d3c4a9b1e0f7d6 0 README.md
100644 3f2a1c8d7e6b5a4930f2e1d0c9b8a7f6e5d4c3b2 0 app/main.py
100755 a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 0 scripts/deploy.sh
120000 c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7 0 config -> config.prod
Читаем: режим (100644 — обычный файл, 100755 — исполняемый, 120000 — симлинк, 160000 — gitlink подмодуля), SHA блоба, stage (нулевой — обычное состояние; 1, 2, 3 появляются только при конфликте слияния, об этом в статье про конфликты), путь.
Три вывода, экономящих часы отладки. Индекс плоский. В нём нет каталогов — только полные пути. Дерево (tree-объекты) строится в момент коммита командой write-tree. Именно поэтому Git «не умеет хранить пустые каталоги»: хранить нечего, записи в индексе нет.
Индекс хранит режим, но только исполняемый бит. Права chmod 640 Git не отслеживает. Если бит +x слетает при выгрузке на Windows или из архива — это видно в git status как изменение файла. Отключается через core.fileMode=false, но чаще правильно — починить права.
Индекс кэширует stat. git status сравнивает ctime/mtime/size/ino записи с реальным файлом. Совпало — файл считается неизменённым без чтения содержимого. Не совпало — Git читает файл, хеширует и сравнивает SHA (файл мог быть перезаписан идентичным содержимым — тогда изменения нет).
У этого кэша есть тонкий баг-по-построению, известный как racy git: если файл изменён в ту же секунду, в которую записывался индекс, mtime совпадёт, и Git решит, что файл чист. Git это знает и помечает такие записи «racily clean», принудительно перечитывая их. Подробности — в Documentation/technical/racy-git. Практический вывод: если git status вдруг «не видит» правку, сделанную скриптом миллисекунду назад, помогает git update-index --refresh или просто touch.
Два флага, которыми злоупотребляют
git update-index --assume-unchanged path/to/file # «обещаю не менять» — для медленных ФС
git update-index --skip-worktree path/to/config # «файла тут быть не должно» — sparse-checkout
Оба используют не по назначению: чтобы «спрятать» локальные правки конфига, который лежит в репозитории. Это работает ровно до первого git pull, который меняет этот файл, — и дальше вы получаете необъяснимые конфликты или молча потерянные изменения. Правильный путь: config.example в репозитории, config в .gitignore. Разница между флагами формально такая: assume-unchanged — обещание вам от Git не проверять файл, которое Git может нарушить; skip-worktree — обещание Git вам не трогать файл, которое соблюдается строже.
Жизненный цикл файла
Обратите внимание на асимметрию переходов «назад». Из Staged в Unmodified содержимое берётся из HEAD и уже лежит в объектной базе — восстановимо. А переход Modified → Unmodified (git restore файл) уничтожает данные безвозвратно: правка нигде не сохранялась, Git её не видел. Это единственная по-настоящему опасная операция в списке, и о ней ниже отдельно.
git status: научиться читать
Обычный вывод многословен и предназначен человеку; для скриптов и быстрого взгляда есть машинные форматы:
$ git status --short --branch
## feature/checkout...origin/feature/checkout [ahead 2, behind 1]
M app/cart.py # изменён и добавлен в индекс
M app/order.py # изменён, но НЕ добавлен
MM app/pricing.py # добавлен, потом ещё раз изменён
A tests/test_cart.py # новый файл, добавлен
?? notes.txt # untracked
!! build/ # игнорируется (виден только с --ignored)
D legacy/old.py # удаление добавлено в индекс
R app/util.py -> app/helpers.py # переименование (вычисленное!)
Две колонки XY: первая — состояние индекса относительно HEAD, вторая — рабочего каталога относительно индекса. Ровно те же два diff’а, что и на схеме выше. MM — файл, изменённый дважды: один раз до add, один после.
Для автоматизации используйте --porcelain=v2 --branch — стабильный формат, не меняющийся между версиями Git и включающий SHA и режимы. Обычный вывод парсить нельзя: он локализуется и меняется. А в больших репозиториях, где status становится узким местом, включают core.untrackedCache true (кэш обхода untracked), core.fsmonitor true (встроенный демон слежения за ФС, Git 2.37+) и git maintenance start (фоновая упаковка, Git 2.30+) — подробнее в статье про монорепозитории.
git add: не «добавить файл», а «записать блоб»
git add file.py делает две вещи: считает содержимое, записывает его как blob-объект в .git/objects (даже если вы никогда не закоммитите), и обновляет запись в индексе. Второе следствие важнее первого: всё, что вы хоть раз добавили в индекс, восстановимо, даже если потом вы это перезаписали. Вернёмся к этому в разделе про отмену.
add -p — главный навык дня
Это та команда, которая отделяет человека с атомарными коммитами от человека, у которого в истории «фикс всего сразу».
$ git add -p app/pricing.py
diff --git a/app/pricing.py b/app/pricing.py
index 3f2a1c8..9d4e7b1 100644
--- a/app/pricing.py
+++ b/app/pricing.py
@@ -12,7 +12,7 @@ def apply_discount(order, rules):
- total = sum(i.price for i in order.items)
+ total = sum(i.price * i.qty for i in order.items) # багфикс: забыли количество
for rule in rules:
if rule.matches(order):
(1/3) Stage this hunk [y,n,q,a,d,j,J,g,/,s,e,?]?
Клавиши, которые реально нужны:
| Клавиша | Что делает |
|---|---|
y / n |
добавить / пропустить этот кусок |
a / d |
все оставшиеся куски в файле — да / нет; q — выйти, сохранив решённое |
s |
split — разрезать кусок на более мелкие |
e |
edit — руками отредактировать патч: удалить строки, поменять + на пробел |
j / k |
отложить решение, перейти к следующему / предыдущему; / — поиск, ? — справка |
s работает только там, где между изменениями есть неизменённые строки-разделители. Если их нет — используйте e: открывается патч в редакторе, где вы удаляете строки +, которые не хотите добавлять, а строки -, которые не хотите удалять, превращаете в контекст (заменяете - на пробел). Это единственный способ разрезать соседние строки.
Патч-режим есть не только у add — тот же интерфейс у git restore -p (выбросить часть правок), git restore --staged -p (убрать часть из индекса), git stash push -p и git checkout -p HEAD~3 -- app/ (выборочно вернуть куски из старого коммита).
add -N: показать Git новый файл, не добавляя содержимое
Untracked-файлы не участвуют в git diff и в add -p — Git о них ничего не знает. Флаг --intent-to-add регистрирует путь в индексе с пустым содержимым:
git add -N new_module.py # или для всего сразу: git add -N .
git add -p new_module.py # теперь новый файл можно добавлять по кускам
Побочный эффект, о который спотыкаются: после add -N файл считается отслеживаемым, поэтому git stash (без -u) его уже прячет, а git commit -a — коммитит.
Про git add . и git commit -a
git add . берёт всё из текущего каталога и ниже, включая мусор, который вы забыли внести в .gitignore. git commit -a вообще пропускает индекс: коммитит все изменения отслеживаемых файлов. Обе удобны и обе — способ регулярно коммитить debug print, закомментированный код и ключи от боевой базы. Минимальная гигиена — git config --global commit.verbose true: после этого git commit открывает редактор, где ниже сообщения показан полный diff того, что вы коммитите. Самая дешёвая мера, ловящая «ой, я не то добавил».
Что происходит при git commit
Здесь модель данных из первой статьи стыкуется с ежедневной работой: коммит — это три сантехнических шага, обёрнутых в одну команду.
только пути, без деревьев IDX->>ODB: git commit → write-tree строит tree-объекты по путям ODB-->>IDX: SHA корневого дерева IDX->>ODB: commit-tree: дерево + родитель + автор + сообщение ODB-->>REF: SHA нового коммита REF->>REF: update-ref сдвигает ветку, HEAD едет за ней
Это не метафора — так можно сделать руками, и раз в жизни это стоит проделать, чтобы модель перестала быть абстракцией:
$ echo "hello, git" | git hash-object -w --stdin
8d0e4122a17a4a4c4c1f5f9b8f0a7d3c2b1e6f5a
$ git update-index --add --cacheinfo 100644,8d0e4122a17a4a4c4c1f5f9b8f0a7d3c2b1e6f5a,hello.txt
$ git write-tree
7c1d2a3b4e5f60718293a4b5c6d7e8f901234567
$ git commit-tree 7c1d2a3b4e5f60718293a4b5c6d7e8f901234567 -p HEAD -m "коммит вручную"
b5a4c3d2e1f00918273645a4b5c6d7e8f9012345
$ git update-ref refs/heads/main b5a4c3d2e1f00918273645a4b5c6d7e8f9012345
$ git log --oneline -1
b5a4c3d коммит вручную
Пять команд — и в истории появился настоящий коммит. git commit не делает ничего сверх этого, кроме хуков (08), проверки подписи и редактора сообщения.
Сообщение коммита
Полноценно про соглашения (Conventional Commits, changelog, релизы) — в статье про совместную работу. Здесь только механика и минимальный стандарт, который окупается всегда:
- Первая строка ≤ 50 символов, повелительное наклонение, без точки. «Исправь округление в расчёте скидки», а не «исправил» и не «исправление». Проверка: строка должна дописываться к фразе «Если применить этот коммит, он …».
- Пустая строка, дальше тело, перенос на 72 символах. Пустая строка — не стиль:
git log --oneline, формат%sи веб-интерфейсы берут именно первую строку. Тело отвечает на «почему», а не «что» — «что» видно в diff. - Ссылка на задачу и на причину. Через полгода
git log -Sприведёт вас именно сюда. Канонический разбор — «How to Write a Git Commit Message» Криса Бимса.
Полезная привычка — правку уже сделанного коммита оформлять как git commit --fixup=a1b2c3d, а потом схлопывать через git -c rebase.autoSquash=true rebase -i a1b2c3d~1: fixup сам встанет на нужное место. Про интерактивный rebase и риски переписывания — статья 05.
Три diff’а, а не один
Самая частая путаница дня. git diff без аргументов не показывает всё, что вы наделали — он показывает только незастейдженное.
| Команда | Что с чем сравнивает | Отвечает на вопрос |
|---|---|---|
git diff |
индекс ↔ рабочий каталог | «что я ещё не добавил» |
git diff --staged (--cached) |
HEAD ↔ индекс | «что уйдёт в коммит» |
git diff HEAD |
HEAD ↔ рабочий каталог | «что я наработал всего» |
git diff main |
коммит ↔ рабочий каталог | «насколько я разошёлся с main» |
git diff main...HEAD |
база слияния ↔ HEAD | «что добавила именно моя ветка» |
git diff --no-index a b |
два пути вне репозитория | Git как обычный diff |
Форма с тремя точками — важнейшая из редко используемых. git diff main..HEAD (две точки, равносильно git diff main HEAD) показывает разницу двух состояний, включая всё, что за это время появилось в main и чего у вас нет. git diff main...HEAD показывает изменения относительно точки расхождения — то есть ровно то, что увидит ревьюер в PR. Если вы хотите проверить, что уйдёт на ревью, — три точки.
Настройки, которые делают diff читаемым
git config --global diff.algorithm histogram # куски по смыслу, а не по Майерсу
git config --global diff.colorMoved zebra # перемещённый код — отдельным цветом
git config --global diff.colorMovedWS allow-indentation-change
git config --global diff.renames copies # ловить не только переименования, но и копии
git config --global diff.renameLimit 3000 # иначе на больших коммитах эвристика сдаётся
Про переименования есть принципиальный момент, вытекающий из модели данных: Git их не хранит. В коммите есть только деревья с именами и SHA. R app/util.py -> app/helpers.py в выводе — результат эвристики, сравнивающей удалённые и добавленные файлы по схожести содержимого (порог по умолчанию 50%, меняется через -M90%). Следствия: если вы переименовали файл и одновременно переписали его на 70%, Git покажет удаление и добавление; и наоборот — переименование отдельным коммитом, без правок, распознаётся идеально и делает историю читаемой.
Ещё один рычаг — .gitattributes. Заголовки кусков @@ ... @@ по умолчанию берут первую подходящую строку выше, и для Python это часто не то:
*.py diff=python
*.go diff=golang
*.tsx diff=typescript
*.lock -diff # не показывать diff огромных lock-файлов
*.png binary
Там же настраивается textconv — конвертер бинарного формата в текст, после которого осмысленный diff появляется у PDF или .docx (git config diff.pdf.textconv pdftotext). А внешние вьюеры добивают удобство: delta даёт подсветку синтаксиса и side-by-side, difftastic сравнивает синтаксические деревья, а не строки, — и переформатирование кода перестаёт выглядеть как переписывание файла.
git stash: это просто коммиты
stash окружён мистикой, хотя устроен прозаично. git stash push создаёт обычные коммиты, которые не принадлежат ни одной ветке, и записывает верхний из них в ссылку refs/stash:
$ git stash push -m "черновик расчёта скидок"
Saved working directory and index state On feature/pricing: черновик расчёта скидок
$ git cat-file -p refs/stash
tree 4a1b2c3d5e6f708192a3b4c5d6e7f80912345678
parent 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432 ← HEAD на момент stash
parent 2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e ← снимок индекса
author Мария Иванова <maria@example.com> 1721030400 +0300
committer Мария Иванова <maria@example.com> 1721030400 +0300
On feature/pricing: черновик расчёта скидок
Два родителя. Первый — коммит, на котором вы стояли. Второй — служебный коммит, хранящий состояние индекса (поэтому stash умеет различать staged и unstaged при --index). С флагом -u появляется третий родитель — коммит с untracked-файлами.
9f8e7d6"] --> S["stash@{0}
дерево = рабочий каталог"] IDXC["коммит индекса
2b3c4d5"] --> S UNT["коммит untracked
только при -u"] -.-> S S --> R["refs/stash
+ .git/logs/refs/stash"] R --> Q["stash@{1}, stash@{2}...
это записи reflog, а не ссылки"]
Последнее на схеме — источник половины проблем со stash: stash@{1} — не отдельная ссылка, а запись в reflog ссылки refs/stash. Отсюда поведение, которое кажется случайным: номера сдвигаются при каждом drop/pop, а git stash clear уничтожает reflog целиком, и записи становятся недостижимыми объектами (найти их потом можно только через git fsck).
Рабочий набор
git stash push -m "описание" # с сообщением — всегда пишите его
git stash push -u # включая untracked
git stash push -p # интерактивно, по кускам
git stash push -m "только тесты" -- tests/ # только указанные пути
git stash show -p stash@{0} # полный diff отложенного
git stash apply --index stash@{0} # применить, вернув разделение staged/unstaged
git stash pop # применить и удалить
git stash branch fix/pricing stash@{0} # ветка от базового коммита + применить
Три ловушки, каждая из которых стоила кому-то рабочего дня:
pop при конфликте не удаляет stash. Это правильно (иначе вы потеряли бы данные), но выглядит так, будто команда «не сработала». После разрешения конфликта нужно вручную git stash drop. И наоборот: если конфликт разрешён криво, stash ещё на месте — не паникуйте.
git stash по умолчанию не трогает untracked-файлы. Классический сценарий потери: отложили работу, переключились на другую ветку, «прибрались» через git clean -fd — и новые файлы, которых не было в stash, исчезли.
Stash — ящик без подписей. Через три дня stash@{2}: WIP on main: 3f2a1c8 Merge pull request #412 не говорит вам ничего. Честный совет: в 90% случаев вместо stash лучше WIP-коммит на ветке — git switch -c wip/pricing && git commit -am "wip", а по возвращении git reset --soft HEAD~1 расформировывает его обратно в индекс. У WIP-коммита есть имя, дата, сообщение, он виден в git log --all, переживает переустановку системы после push и его не съест git stash clear. Stash оправдан ровно там, где он задуман: «на минуту отложить, чтобы сделать git pull».
Карта отмены изменений
Здесь и ломаются заклинания: правильный вопрос не «какая команда отменяет», а «что именно я хочу вернуть и в каком из трёх деревьев оно сейчас лежит».
в коммите?"} Q1 -->|Нет| Q2{"Оно в индексе
(staged)?"} Q2 -->|Нет, только в файлах| A1["git restore <путь>
ВНИМАНИЕ: данные пропадут навсегда"] Q2 -->|Да| A2["git restore --staged <путь>
правки останутся в файлах"] Q1 -->|Да| Q3{"Коммит уже
в общей ветке?"} Q3 -->|Нет| Q4{"Переделать
или выбросить?"} Q4 -->|Поправить последний| A3["git commit --amend"] Q4 -->|Расформировать| A4["git reset --soft HEAD~1"] Q4 -->|Выбросить совсем| A5["git reset --hard HEAD~1
коммит найдётся в reflog"] Q3 -->|Да, другие уже подтянули| A6["git revert <sha>
новый коммит с обратным изменением"] START --> Q5{"Это untracked-мусор?"} Q5 -->|Да| A7["git clean -nd, посмотреть,
потом git clean -fd"]
restore и switch: новая грамматика
До Git 2.23 (2019) всё делал перегруженный git checkout: он и ветки переключал, и файлы восстанавливал, и ветки создавал. Одна команда с четырьмя несовместимыми смыслами, различающимися наличием -- в аргументах, — источник катастроф вроде «хотел переключиться на ветку, а имя совпало с файлом». Команду разделили:
git switch main # только ветки
git switch -c feature/x # создать и переключиться
git switch - # предыдущая ветка
git switch --detach a1b2c3d # отсоединённый HEAD осознанно
git restore app/cart.py # из индекса → в файл
git restore --staged app/cart.py # из HEAD → в индекс (unstage)
git restore --staged --worktree app/ # и туда, и туда
git restore --source=HEAD~3 -- app/ # из конкретного коммита
git checkout никуда не делся и не устарел формально, но в новых скриптах и в объяснениях коллегам используйте switch/restore: они делают ровно одно, и по команде видно намерение.
reset: три глубины
git reset --soft HEAD~1 # ветка назад; индекс и файлы не тронуты
git reset HEAD~1 # = --mixed: ветка + индекс; файлы не тронуты
git reset --hard HEAD~1 # всё три дерева; несохранённое стёрто
Применяют так: --soft — «коммит правильный по содержанию, но не по разбивке или сообщению», всё возвращается в индекс и пересобирается; --mixed — «закоммитил не то, хочу пересобрать с нуля через add -p»; --hard — «этой работы не должно существовать», единственный режим, который теряет данные. Есть ещё --merge и --keep: оба пытаются сохранить незакоммиченные правки и отказываются работать, если это невозможно. --keep — разумный «безопасный --hard».
Двойственность reset — главный источник путаницы. С путём и без пути это фактически разные команды:
git reset --hard HEAD~1 # двигает ВЕТКУ, режимы работают
git reset HEAD~1 -- app/x.py # ветку НЕ двигает, копирует файл из коммита в индекс
Именно ради устранения этой двойственности и появился git restore --staged: используйте его, git reset HEAD <файл> работает, но вводит в заблуждение. И ещё один нюанс --hard, который спасает и подводит одновременно: он не удаляет untracked-файлы. «Сброшу всё начисто» через git reset --hard оставит непонятный мусор; полная очистка — это git reset --hard и git clean -fd.
clean: единственная команда, всегда запускаемая дважды
git clean -nd # -n = dry run: ПОКАЗАТЬ, что будет удалено
git clean -fd # -f = force, -d = включая каталоги; -i = интерактивно
git clean -fdx # -x = включая игнорируемые (.env, node_modules, венвы!)
git clean удаляет файлы мимо корзины и мимо Git — они нигде не сохранялись, восстановить их нельзя ничем. Флаг -x регулярно уносит .env с локальными секретами и часовые сборки node_modules. Правило: -n перед каждым -f, всегда, даже когда вы уверены.
revert против reset
reset переписывает историю ветки. Если ветку уже кто-то забрал, ваш git push --force сломает работу коллег: у них старые коммиты, у вас новые, следующий pull устроит им «merge своей же истории». Для уже опубликованных коммитов правильный инструмент — revert:
git revert a1b2c3d # новый коммит, отменяющий a1b2c3d
git revert --no-commit a1b2c3d b4c5d6e # накопить несколько отмен в индексе
git revert -m 1 <merge-sha> # откатить merge относительно первого родителя
revert ничего не удаляет — он добавляет коммит с обратным патчем. История растёт, зато она честная и безопасная для всех. Про откат merge-коммитов и его коварное последствие (повторное слияние той же ветки не вернёт изменения) — в статье про merge и rebase.
Что восстановимо, а что нет
Прямое следствие модели данных, стоит выучить наизусть:
| Состояние в момент потери | Восстановимо? | Как |
|---|---|---|
Правка была только в файле, не было add |
Нет | никак (разве что бэкап ФС или undo-история редактора) |
Был git add, потом потеряно |
Да | blob уже в .git/objects — git fsck --lost-found |
| Был коммит на ветке, ветка сброшена | Да | git reflog — коммит достижим ~90 дней |
| Ветка удалена без merge | Да | git reflog и git fsck --unreachable |
git stash drop / clear |
Да | git fsck --unreachable | grep commit |
Файлы удалены git clean |
Нет | никак |
Был git gc --prune=now после потери |
Нет | объекты физически удалены |
reflog — журнал всех перемещений HEAD и веток, и это отдельная суперсила, которой посвящена статья 07. Здесь — минимум, который надо помнить в момент паники:
$ git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~2
9f8e7d6 HEAD@{1}: commit: добавь валидацию промокода
3c4d5e6 HEAD@{2}: commit: вынеси расчёт скидки в сервис
$ git reset --hard HEAD@{1} # всё вернулось
Первое правило восстановления: прежде чем что-либо чинить — ничего не делайте и запустите git reflog. Второе: не запускайте git gc --prune=now, пока не вернули потерянное.
.gitignore и почему он «не работает»
Три уровня правил действуют одновременно: .gitignore в репозитории (коммитится, общий для команды), .git/info/exclude (локальный для клона) и глобальный ~/.config/git/ignore (core.excludesFile). Личные исключения — .idea/, .DS_Store, *.swp — кладите в глобальный: репозиторий не должен знать, какой у вас редактор.
Главная причина «.gitignore не работает»: правила не действуют на уже отслеживаемые файлы. Если файл однажды закоммитили, Git следит за ним, что бы вы ни написали в игноре. Лечение:
git rm --cached path/to/file # убрать из индекса, оставив на диске
git rm -r --cached . && git add . # тотально: пересобрать индекс по .gitignore
Вторая причина — неправильно понятое правило; отлаживается одной командой:
$ git check-ignore -v build/output.js
.gitignore:14:build/ build/output.js
Она показывает файл, номер строки и само правило; пустой вывод означает, что файл не игнорируется ничем и проблема в другом. Отдельно помните: исключить файл из игнорируемого каталога нельзя — !build/keep.txt не сработает, если игнорируется build/ целиком, потому что Git не заходит внутрь. Нужно писать build/* вместо build/.
Конфигурация, которая окупается в первый день
git config --global commit.verbose true # diff в редакторе при коммите
git config --global push.autoSetupRemote true # ветка сама заводит upstream
git config --global pull.rebase true # pull без случайных merge-коммитов (статья 04)
git config --global rerere.enabled true # запоминать разрешения конфликтов (статья 06)
git config --global merge.conflictStyle zdiff3 # в маркерах конфликта виден общий предок
git config --global branch.sort -committerdate # ветки по свежести, а не по алфавиту
git config --global help.autocorrect prompt # переспросить при опечатке в команде
git config --global core.autocrlf input # на macOS/Linux; на Windows — true
# алиасы, которые правда экономят время (остальные вредят: забываете настоящие команды)
git config --global alias.st "status -sb"
git config --global alias.lg "log --oneline --graph --decorate --all -20"
git config --global alias.unstage "restore --staged"
git config --global alias.wip "!git add -A && git commit -m 'wip' --no-verify"
Про pull.rebase, rerere и zdiff3 подробно — в статьях про merge и rebase и конфликты.
Типичные ошибки
Коммит-«свалка». Один коммит с рефакторингом, багфиксом и новой фичей нельзя ни отревьюить, ни отменить, ни найти через git bisect. Лечится не дисциплиной, а git add -p: когда вы просматриваете каждый кусок, вы физически видите, что смешали три задачи.
git diff вместо git diff --staged перед коммитом. Смотрите не туда и коммитите не то. Решение — commit.verbose.
Push после reset в общей ветке. --force-with-lease безопаснее --force (откажется, если удалённая ветка ушла вперёд), но для общей ветки правильный ответ — revert. Подробности в 05.
git stash как долговременное хранилище и --assume-unchanged для локальных конфигов. Первое даёт через неделю пять безымянных stash’ей, второе — необъяснимые ошибки merge при первом же изменении файла в origin. Лечение: ветки и config.example + .gitignore.
Удаление .git/index.lock руками. Иногда единственный выход (если процесс правда мёртв), но чаще это признак параллельно работающей IDE или хука. Сначала посмотрите ps.
Слепая вера в git checkout . Молча уничтожает всю несохранённую работу в каталоге. Привычка-страховка: сначала git stash push — он же «отмена с возможностью передумать».
Мини-итог
- Три дерева: HEAD (последний коммит), индекс (что уйдёт в следующий коммит), рабочий каталог (файлы на диске). Все команды дня — копирование между ними.
git statusиgit diffне показывают «изменения» — они сравнивают пары деревьев. Знаете пару — знаете, что увидите.- Индекс — плоский список путей с SHA блобов и stat-кэшем. Отсюда: нет пустых каталогов, нет хранения переименований, быстрый
status. git add -p— навык, который делает историю читаемой.git add -Nподключает к нему новые файлы.- Три diff’а:
git diff(не добавлено),--staged(уйдёт в коммит),HEAD(всё). Для ревью —main...HEADс тремя точками. - Stash — обычные коммиты с двумя-тремя родителями плюс reflog. Для чего-то дольше часа берите WIP-ветку.
- Отмена: не добавляли — потеряно навсегда; добавляли — есть blob; коммитили — есть reflog.
git cleanне отменяется ничем. Аreset --soft/--mixed/--hard— это просто глубина проникновения отката в три дерева; опубликованное отменяется черезrevert, не черезreset.
Источники
- Скотт Чакон, Бен Штрауб. Pro Git, 2-е изд. — главы 2 «Основы Git» и 7.7 «Reset. Демистификация», лучший разбор трёх деревьев на русском.
- Документация Git: git-add, git-restore, git-stash, git-diff, git-reset, gitignore.
- Формат индекса: index-format и racy-git.
- John Wiegley. Git from the Bottom Up — короткая книга, объясняющая Git ровно через модель данных.
- Chris Beams. How to Write a Git Commit Message — семь правил, ставших де-факто стандартом.
- Инструменты: delta (подсветка diff), difftastic (структурный diff по AST), tig (терминальный обозреватель истории).
Что дальше
Теперь у вас есть локальный цикл: изменил → просмотрел → собрал коммит → при необходимости откатил. Следующий вопрос — как эти коммиты организованы в ветки и как команда договаривается о том, куда они вливаются. Триста разработчиков в одном main и три ветки на каждую задачу — это два разных мира с разными узкими местами.
Стратегии ветвления: trunk-based, GitFlow, GitHub Flow — что когда