Git и командная работа Ежедневная работа: staging, коммиты, diff, stash, отмена изменений
0%

Ежедневная работа: staging, коммиты, diff, stash, отмена изменений

Ежедневная работа: 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: HEAD, индекс и рабочий каталог

Ключ ко всему дальнейшему: 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

Здесь модель данных из первой статьи стыкуется с ежедневной работой: коммит — это три сантехнических шага, обёрнутых в одну команду.

Это не метафора — так можно сделать руками, и раз в жизни это стоит проделать, чтобы модель перестала быть абстракцией:

$ 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-файлами.

Последнее на схеме — источник половины проблем со 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».

Карта отмены изменений

Здесь и ломаются заклинания: правильный вопрос не «какая команда отменяет», а «что именно я хочу вернуть и в каком из трёх деревьев оно сейчас лежит».

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

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/objectsgit 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.

Источники

Что дальше

Теперь у вас есть локальный цикл: изменил → просмотрел → собрал коммит → при необходимости откатил. Следующий вопрос — как эти коммиты организованы в ветки и как команда договаривается о том, куда они вливаются. Триста разработчиков в одном main и три ветки на каждую задачу — это два разных мира с разными узкими местами.

Стратегии ветвления: trunk-based, GitFlow, GitHub Flow — что когда

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов