Перейти к содержимому

Рабочий цикл: staging, commit, diff, status

Теперь, когда ты знаешь из предыдущей главы, что внутри .git/ лежит база объектов, refs и index-файл, пора превратить это знание в ежедневный навык. Рабочий цикл в Git — это конвейер из трёх областей: рабочая директория (файлы, которые ты редактируешь), staging area / index (снимок будущего коммита) и репозиторий (неизменяемая история). Вся сила Git — в том, что эти области разделены: ты можешь править десять файлов, но зафиксировать в одном коммите три, а остальные оставить «в работе».

В продакшене этот цикл — валюта командной работы. Ревьювер читает твои коммиты по одному, и если каждый коммит атомарный и с хорошим сообщением, код-ревью проходит в разы быстрее. CI роняет сборку — по истории понятно, какой коммит виноват и зачем он был нужен. Через полгода git blame и git log по файлу расскажут историю решений лучше любой wiki. Всё это начинается с дисциплины на уровне addcommit.

Разбираем конвейер целиком: схема областей, git add под микроскопом, .gitignore во всех правилах, --amend, философия маленьких коммитов, conventional commits и чтение git status/git diff без единой непонятной строки.

git add git commit
┌───────────────┐ ┌───────────────┐ ┌───────────────────┐
│ РАБОЧАЯ │ ─────▶ │ INDEX │ ─────▶ │ РЕПОЗИТОРИЙ │
│ ДИРЕКТОРИЯ │ │ (STAGING) │ │ (.git/objects) │
│ │ │ │ │ │
│ файлы как │ │ снимок того, │ │ неизменяемые │
│ их правишь │ │ что войдёт в │ │ commit → tree → │
│ ты прямо │ │ следующий │ │ blobs │
│ сейчас │ │ коммит │ │ │
└───────┬───────┘ └───────────────┘ └───────────────────┘
│ │ │
│◀──────────────────────┴────── git checkout/restore (файл)
│◀────────────────────────────────── git reset (mixed/hard)
│◀────────────────────────────────── git merge/rebase

Важно: все стрелки двусторонние по смыслу — Git умеет двигать изменения между любыми двумя областями (git diff сравнивает любую пару, restore/reset/checkout переносят содержимое назад). В этой главе фокус на пути «вперёд»: workdir → index → repository.

Типичная сессия выглядит так:

Окно терминала
# правишь файлы в редакторе...
git status # что изменилось?
git diff # как именно изменилось (workdir vs index)
git add src/auth.ts # кандидат в коммит №1
git add -p src/api.ts # из файла — только часть хунков в коммит №1
git commit -m "feat(auth): вход через OAuth"
git add src/api.ts # остаток правок — в коммит №2
git commit -m "refactor(api): типизировать ответы"

git add (см. git-add(1); механика — в главе про internals) делает три вещи: пишет blob в базу, считает хэш, обновляет запись в index. Но у команды есть нюансы, которые бьют каждый день:

  • git add файл фиксирует снимок на момент вызова. Если после add снова поправить файл — в index останется старая версия, а git status покажет одновременно «staged: modified» и «unstaged: modified». Нужна актуальная версия — add придётся повторить.
  • -p (patch mode) — самый профессиональный режим. Git показывает изменения «хунками» (кусками diff) и спрашивает: y — взять, n — пропустить, s — разбить хунк мельче, e — отредактировать вручную. Так из одного файла делают два атомарных коммита.
  • -A / . добавляют всё, включая удалённые файлы (git add -A эквивалентен git add .; git add -u). Удобно, но опасно: мусор попадёт в коммит.
  • git add -f принудительно добавляет файл, который игнорируется .gitignore (например, запиннить изменённый package-lock.json, если он в игноре).

Проверить, что реально в index:

Окно терминала
git diff --cached # staged: index vs HEAD
git ls-files --stage # сырой список записей index

.gitignore (полный синтаксис паттернов — gitignore(5)) — текстовый файл с паттернами, по одному на строку. Git проверяет паттерны против пути файла и решает: untracked-файлы, попавшие под паттерн, вообще не показываются в git status и не добавляются массово.

# 1. Комментарий — строка на #
# 2. Обычный файл или папка (в любой директории репозитория)
node_modules/
*.log
dist/
# 3. Ведущий слэш — от корня репозитория (без слэша — везде)
/build/ # только корневая build/
temp/ # temp/ на ЛЮБОМ уровне
# 4. Ведущий ! — отрицание (исключение из игнора)
*.env
!.env.example # example всё-таки коммитим
# 5. Двойная звезда — на любую глубину вложенности
**/coverage/
docs/**/*.pdf
# 6. Звезда не пересекает слэш; вопрос — один символ; [abc] — класс
photo-????.jpg # photo-0001.jpg, но не photo-00001.jpg
src/[0-9]*.ts
# 7. Слэш в конце — только директории
logs/ # игнорирует директорию logs/, но не файл logs

Три грабли, о которых забывают:

  1. Игнор не действует на уже отслеживаемые файлы. Если config.env уже в истории, строка в .gitignore ничего не изменит. Нужно сначала git rm --cached config.env (удалит из index, оставит на диске) и закоммить.
  2. Пробелы и спецсимволы экранируются бэкслешем: my\ file.txt.
  3. Порядок имеет значение: более позднее отрицание ! побеждает ранний игнор, но не может «вернуть» файл, если игнор заблокировал родительскую директорию — тогда Git вообще не смотрит внутрь. Правильно: не игнорить саму папку, а игнорить её содержимое с исключениями:
# ПЛОХО: logs/ потом !logs/important.log — не сработает
# ХОРОШО:
logs/*
!logs/important.log

Коммит должен быть атомарным: одно логическое изменение, которое (в идеале) самостоятельно компилируется, проходит тесты и имеет смысл при revert. «Коммичу всё разом в пятницу вечером» — антипаттерн: ревью превращается в мучение, git bisect (поиск коммита, сломавшего сборку) бессмысленен, откат одной «фичи» из каши невозможен.

Практический тест атомарности: можешь ли ты описать коммит одним предложением без слов «и», «также», «заодно»? Нет — разбивай.

git commit --amend (см. git-commit(1)) дописывает текущий index в предыдущий коммит, а не создаёт новый:

до amend: после amend:
A ← B ← C(main) A ← B ← C'(main)
^ index: новые правки ^
| index пуст, C' =
└─ твои правки (ещё не в истории) C + правки

C’ — новый объект с новым хэшем (другой родитель? нет, тот же, но другое дерево и сообщение). Старый C остаётся в базе до gc. Отсюда железное правило: не amend’ишь коммиты, которые уже запушены — у коллег C, у тебя C’, и это конфликт истории (потребуется force-push).

Amend удобен, чтобы дописать забытый файл в «только что сделанный» коммит или поправить опечатку в сообщении:

Окно терминала
git add forgotten.ts
git commit --amend --no-edit # дописать файлы, сообщение не трогать
git commit --amend -m "feat(auth): исправить опечатку в сообщении"

Хорошие сообщения. Правило индустрии (классика жанра — статья Chris Beams «How to Write a Git Commit Message») — первая строка (subject) до 72 символов, в повелительном наклонении, без точки в конце; далее пустая строка и тело с мотивацией («почему», а не «что» — «что» видно в diff):

исправить гонку при одновременной записи сессии
Раньше два параллельных запроса могли перезаписать
сессионный файл друг друга: запись не была атомарной.
Теперь пишем во временный файл и делаем rename,
что атомарно на POSIX.
Задача: PROJ-1234

Conventional Commits — стандарт сообщений, от которого растут автоматический чейнджлоги, семантическое версионирование и триггеры CI:

feat(api): добавить эндпоинт обновления профиля
└─┬─┘ └┬┘ └──────────────────┬─────────────────┘
│ │ └─ subject: что, в повелительном наклонении
│ └─ scope (опционально): область кода
└─ type: feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert

Правила:

  1. type обязателен, feat и fix влияют на версию (minor/patch в semver-релизах).
  2. BREAKING CHANGE: в теле или ! после type/scope — major-версия: feat(api)!: удалить устаревший /v1/users.
  3. Scope — свободное слово из терминологии проекта (api, ui, auth), помогает быстрой навигации по истории.
  4. Subject — без заглавной буквы и без точки (соглашение, а не догма).

Практическая ценность:

Окно терминала
# автоматический чейнджлог из истории
git log --pretty=format:"%s" v1.0.0..HEAD | grep "^feat" > changelog-features.txt
# CI: линт коммитов через commitlint, релизы через release-please

git status сравнивает три области и раскладывает файлы по корзинам:

On branch main
Your branch is ahead of 'origin/main' by 2 commits. ← heads/main vs
remotes/origin/main
Changes to be committed: ← index ≠ HEAD (попадёт в коммит)
(use "git restore --staged <file>..." to unstage)
modified: src/auth.ts
Changes not staged for commit: ← workdir ≠ index (не попадёт!)
(use "git add <file>..." to update what will be committed)
modified: src/api.ts
Untracked files: ← есть на диске, нет ни в index, ни в HEAD
(use "git add <file>..." to include in what will be committed)
src/new-module.ts

Три зоны вывода — это ровно три пары различий «область vs область». Соответствующие git diff (см. git-diff(1)):

Окно терминала
git diff # workdir vs index (что НЕ попадёт в коммит)
git diff --cached # index vs HEAD (что попадёт в коммит)
git diff HEAD # workdir vs HEAD (всё несохранённое, суммарно)
git diff main feature # два коммита/refs целиком

Один полный проход «как про» перед каждым коммитом:

Окно терминала
git status # карта: что где лежит
git diff # ревью своих незакоммиченных правок
git add -p # выборочная индексация
git diff --cached # финальное ревью будущего коммита
git commit -m "feat(auth): ..."

Три приёма, которые отличают «коммичу как получится» от «коммичу как инженер»:

git commit -v — открывает редактор с полным diff будущего коммита прямо под сообщением. Последний шанс отреагировать: «постой, я не хотел коммитить этот файл» или «здесь остался отладочный console.log». Приучайся: сообщение пишешь, глядя на реальный diff, а не на память.

git commit -am "..." — комбинация «добавить все отслеживаемые + закоммитить». Удобно, но с двумя ловушками: untracked-файлы не добавляются (новый файл молча не попадёт в коммит), а частично проиндексированные правки будут проигнорированы — -a перезапишет index полным снимком рабочей директории. Для атомарной работы с -p эта команда не подходит в принципе.

--fixup и --autosquash — механизм «доложить правки к существующему коммиту» без ручного rebase. Работает так:

Окно терминала
# история: A ← B ← C(main), в B надо дописать правки
git add -p # индексируем только нужное
git commit --fixup B # создаётся коммит "fixup! <сообщение B>"
git rebase -i --autosquash main # Git сам расставит squash к B

Результат: B дополнен правками, коммит fixup! исчезает, история чистая. В паре с rebase --autosquash это стандартный способ реагировать на замечания ревьюера: каждое замечание — отдельный fixup, а перед мерджем история собирается автоматически. Полную механику rebase разбираем в главе про merge/rebase.

  1. «Закоммитил и сразу вижу опечатку — правлю файл и коммичу “fix typo”». Лишний коммит в истории. Если не пушил — git commit --amend --no-edit. Пушил — git revert или отдельный фикс (см. главу про merge/rebase).
  2. Коммит-монстр «фича целиком, разом». Сложно ревьюить, невозможно bisect’ить. Решение: git add -p и серия атомарных коммитов.
  3. .gitignore не работает на уже закоммиченный файл. git rm --cached файл + коммит — только так. Частый случай: запушили .env — после чистки его всё равно надо считать скомпрометированным и менять секреты.
  4. git add . в корне «по привычке». Тянет сгенерированные файлы, локальные конфиги, временные артефакты. Приучайся добавлять осознанно.
  5. Сообщение «update» / «fixes» / «wip». Через полгода это мусор. Минимум: тип + область + действие. Идеал: тело с мотивацией.
  6. Игнор с папка/ и потом удивление, почему !папка/важный.txt не работает. Git не заходит в игнорируемую директорию. Игнори папка/* + !папка/важный.txt.
  7. git diff «пустой», а файл менялся. Скорее всего изменения уже в index — смотри git diff --cached.
  1. Чем staged отличается от unstaged? Staged — запись в index отличается от HEAD (попадёт в коммит); unstaged — рабочая директория отличается от index (не попадёт). Оба состояния могут существовать у одного файла одновременно.
  2. Как разбить правки одного файла на два коммита? git add -p файл: интерактивно выбрать хунки в первый коммит, закоммитить, затем git add остаток и второй коммит.
  3. Что делает git commit --amend? Создаёт ли новый коммит? Создаёт новый объект коммита (C’) с тем же родителем, но новым деревом/сообщением, и двигает ветку. Старый коммит остаётся в базе до gc. Поэтому нельзя amend’ить запушенное.
  4. Файл в .gitignore, но git status всё равно показывает его как modified. Почему? Файл уже отслеживается (есть в HEAD). Игнор действует только на untracked-файлы. Лечится git rm --cached.
  5. Как посмотреть, что войдёт в следующий коммит? git diff --cached (index vs HEAD). А «что я наменял с последнего add» — git diff (workdir vs index).
  6. Зачем Conventional Commits, кроме красивых сообщений? Машиночитаемая история: автоматические чейнджлоги, semver-релизы (feat→minor, fix→patch, BREAKING→major), условные CI-триггеры, линтинг через commitlint.
  7. Паттерн *.log игнорирует logs/debug.log? А /build/src/build/? Первый — да (без слэша паттерн действует на любую глубину). Второй — нет: ведущий слэш якорит паттерн к корню репозитория.
  1. В песочном репозитории сделай правки в трёх файлах так, чтобы один был «staged + ещё доработан после add», второй — только unstaged, третий — untracked. Сделай так, чтобы git status показал все три корзины одновременно. Критерий: вывод статуса объясним каждой строкой.
  2. Разбей правки одного файла на два атомарных коммита через git add -p (понадобится файл с двумя независимыми изменениями). Критерий: git log -p показывает два коммита, каждый с одним логическим изменением.
  3. Настрой .gitignore для проекта: node_modules/, dist/, *.log, .env с исключением .env.example, директория data/ с исключением data/seed.json. Проверь: git status --ignored показывает ожидаемое.
  4. Создай файл, закоммить его, добавь в .gitignore, убедись, что игнор не сработал. Исправь ситуацию правильной командой. Критерий: после фикса файл modified больше не светится, но остался на диске.
  5. Напиши серию из пяти коммитов в формате Conventional Commits с разными type (feat, fix, refactor, docs, test) и одним с !. Критерий: git log --oneline читается как мини-чейнджлог; git log --grep="^feat" находит фичи.
  6. Закоммить файл с опечаткой в сообщении, не пуша. Исправь сообщение через --amend. Критерий: git log --oneline показывает исправленное сообщение, а в reflog (git reflog) виден и старый, и новый коммит.