voidlearn
 

2.1Коммит

Соберёшь commit из выбранных изменений и проверишь содержимое index до записи. Разберёшь атомарное сообщение, Conventional Commits и последствия amend после публикации.

Что сможешь объяснить после этой главы
  • Что значат две буквы перед именем файла в git status -s и строка ## с ahead и behind.
  • Как разложить правки одного файла на два коммита через git add -p и что делать, когда кусок больше не делится.
  • Какой коммит называют атомарным и где это окупается.
  • Из чего состоит сообщение коммита и куда git дел строку, начатую с #412.
  • Как тип коммита в Conventional Commits превращается в номер версии.
  • Почему git commit --amend после push ломает историю.

status -sb: две колонки и строка ветки

Пример на всю главу: клиент погоды на Go, где fetch из client.go ходит в сервис через http.Get. Первый коммит ff79116 уже в origin. Аня добавляет клиенту таймаут, по ходу вставляет отладочную печать, собирает бинарь, добавляет client.go в индекс и только потом замечает в тексте ошибки опечатку fecth. Исправляет её и дописывает строку в README.md.

$ git status -sb
## main...origin/main
 M README.md
MM client.go
?? weather

Обычный git status тратит на это семнадцать строк. -s (short) оставляет по строке на файл, -b (branch) добавляет строку ветки. Левая буква сравнивает HEAD с индексом: это добавлено и уйдёт в коммит. Правая сравнивает индекс с диском: это есть в файле, но в коммит не попадёт. Пробел значит, что разницы нет. У client.go заняты обе колонки: в индексе версия с таймаутом, на диске она же с исправленной опечаткой (три версии в трёх снимках, как в главе 1.3). Бинарь weather индексу неизвестен, отсюда два вопроса.

КодЧто произошлоЧто возьмёт git commit
 Mфайл изменён на диске, в индекс не добавленничего, файл останется как в HEAD
M изменение добавлено, диск совпадает с индексомизменение целиком
MMдобавлено, потом файл поправили ещё разверсию из индекса, без второй правки
A , AMновый файл добавлен; во втором случае правлен после addфайл в том виде, в каком его добавили
D  /  Dудалён через git rm / обычным rmудаление / ничего, но commit -a его подхватит
R переименован; git угадывает это по содержимому (глава 1.1), так что и без git mvпереименование старый -> новый
?? / !!неотслеживаемый / игнорируемый, второй виден только с --ignoredничего

При конфликте буквы значат другое, там встречается UU (глава 3.2). Строка ## main...origin/main называет ветку и её upstream (глава 4.1). Появятся локальные коммиты, и в конце встанет [ahead 2]; разойдутся ветки, будет [ahead 1, behind 1]. Сервер при этом никто не спрашивает, счёт идёт по локальной origin/main после последнего fetch или push. Скриптам нужен --porcelain: те же коды, но формат не меняется между версиями, цвета нет, пути от корня репозитория.

А git add . сейчас забрал бы и бинарь:

$ git add --dry-run .
add 'README.md'
add 'client.go'
add 'weather'
$ du -h weather
7.9M    weather

Восемь мегабайт остались бы в истории навсегда (глава 1.4). Артефакты сборки прячут через .gitignore (глава 2.2), а пока его нет, файлы добавляют по именам.

Перед коммитом: diff --staged и commit -v

MM значит, что коммит возьмёт версию из индекса. Какую именно, показывает git diff --staged (глава 1.3):

$ git diff --staged
diff --git a/client.go b/client.go
index d419f97..65d9d40 100644
--- a/client.go
+++ b/client.go
@@ -3,10 +3,14 @@ package main
 import (
        "fmt"
        "net/http"
+       "time"
 )

+var client = &http.Client{Timeout: 5 * time.Second}
+
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
+       fmt.Println("DEBUG fetch", url)
        if err != nil {
                return nil, fmt.Errorf("fecth %s: %w", url, err)
        }

В индексе отладочная печать, а fecth на месте: опечатку исправили после add. Тот же дифф можно держать перед глазами, пока пишешь сообщение: git commit -v (verbose) кладёт его в редактор под шаблон.

# Untracked files:
#       weather
#
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
diff --git a/client.go b/client.go
index d419f97..65d9d40 100644

Всё ниже линии с ножницами в сообщение не попадёт; включить так навсегда можно настройкой commit.verbose. Пустое сообщение отменяет коммит, и Аня так и поступила, решив собрать два коммита: опечатка отдельно, таймаут отдельно, отладка никуда. Сначала она возвращает индекс к HEAD, не трогая диск (restore разобран в главе 2.2):

$ git restore --staged client.go
$ git status -s
 M README.md
 M client.go
?? weather

add -p: коммит из части файла

git add -p (patch) режет дифф индекса и рабочей копии на куски и про каждый спрашивает, добавлять ли его. Правки, между которыми не больше шести неизменённых строк, попадают в один кусок: три строки контекста вокруг каждой смыкаются. У Ани кусок получился один (вывод git 2.54):

$ git add -p client.go
diff --git a/client.go b/client.go
index d419f97..0763f8f 100644
--- a/client.go
+++ b/client.go
@@ -3,12 +3,16 @@ package main
 import (
        "fmt"
        "net/http"
+       "time"
 )

+var client = &http.Client{Timeout: 5 * time.Second}
+
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
+       fmt.Println("DEBUG fetch", url)
        if err != nil {
-               return nil, fmt.Errorf("fecth %s: %w", url, err)
+               return nil, fmt.Errorf("fetch %s: %w", url, err)
        }
        return resp, nil
 }
(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]? s
Split into 4 hunks.
@@ -3,5 +3,6 @@ package main
 import (
        "fmt"
        "net/http"
+       "time"
 )

(1/4) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? n
@@ -6,3 +7,5 @@
 )

+var client = &http.Client{Timeout: 5 * time.Second}
+
 func fetch(url string) (*http.Response, error) {
(2/4) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? n
@@ -8,3 +11,4 @@
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
+       fmt.Println("DEBUG fetch", url)
        if err != nil {
(3/4) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? n
@@ -10,5 +14,5 @@
        if err != nil {
-               return nil, fmt.Errorf("fecth %s: %w", url, err)
+               return nil, fmt.Errorf("fetch %s: %w", url, err)
        }
        return resp, nil
 }
(4/4) Stage this hunk [y,n,q,a,d,K,J,g,/,e,p,P,?]? y

Ответ s (split) разрезал кусок на четыре. Три Аня пропустила, четвёртый, с опечаткой, добавила, и git записал выбранное в индекс. После разрезания s нет ни у одного куска: каждый из них одна сплошная правка. У четвёртого нет ещё k и j: они ведут к нерешённым кускам, а таких не осталось.

ОтветЧто делает
y / nдобавить кусок в индекс или пропустить
sразрезать кусок по неизменённым строкам между правками
eоткрыть кусок в редакторе и поправить руками
a / dдобавить или пропустить этот и все оставшиеся куски файла
qвыйти; то, что выбрано до этого, попадёт в индекс
?подсказка по остальным буквам: переходы между кусками, поиск, повторный показ
$ git status -s
 M README.md
MM client.go
?? weather
$ git diff --staged --stat
 client.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Атомарный коммит

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

  • Ревью. Коммит читается как одна мысль, не надо гадать, какая строка к какой задаче.
  • Откат. Окажется таймаут мал, его откатят через git revert (глава 5.1), а исправленная опечатка останется.
  • Поиск поломки. git bisect (глава 5.3) указывает на коммит целиком, и чем он меньше, тем точнее ответ. Несобирающийся коммит проверить нельзя, его пропускают через git bisect skip, и граница размывается.
  • Перенос. Исправление уходит в релизную ветку через cherry-pick (глава 3.3) без недоделанной фичи.

Атомарный не значит маленький. Переименование функции со всеми вызовами остаётся одним коммитом, даже если задело сорок файлов: разрежь его, и промежуточный коммит не соберётся.

Собираться должен код из индекса, а на диске у Ани лежит больше: таймаут и отладка. git stash push --keep-index прячет всё недобавленное и оставляет на диске ровно версию из индекса:

$ git stash push --keep-index -q
$ git status -s
M  client.go
?? weather
$ go build ./... && go vet ./...
$ git stash pop -q
$ git status -s
 M README.md
MM client.go
?? weather
$ git commit -m "Исправить опечатку в тексте ошибки"
[main b7e8b34] Исправить опечатку в тексте ошибки
 1 file changed, 1 insertion(+), 1 deletion(-)

Сборка и go vet промолчали; в проекте с тестами сюда же добавляют go test ./.... Неотслеживаемые файлы stash без -u не прячет, и в сборке они участвуют. Как устроен stash, разобрано в главе 2.2.

Когда s не помогает: правка куска вручную

Второй проход за таймаутом. Импорт и переменную client Аня берёт как есть, а в третьем куске замена http.Get стоит вплотную к отладке, и на него она отвечает e (edit):

$ git add -p client.go     # s, затем y для импорта и y для переменной
@@ -8,5 +11,6 @@
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
+       fmt.Println("DEBUG fetch", url)
        if err != nil {
                return nil, fmt.Errorf("fetch %s: %w", url, err)
        }
(3/3) Stage this hunk [y,n,q,a,d,K,J,g,/,e,p,P,?]? e

Git 2.54 открывает в редакторе файл .git/addp-hunk-edit.diff и дописывает в конец правила:

# Manual hunk edit mode -- see bottom for a quick guide.
@@ -8,5 +11,6 @@
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
+       fmt.Println("DEBUG fetch", url)
        if err != nil {
                return nil, fmt.Errorf("fetch %s: %w", url, err)
        }
# ---
# To remove '-' lines, make them ' ' lines (context).
# To remove '+' lines, delete them.
# Lines starting with # will be removed.
# If the patch applies cleanly, the edited hunk will immediately be marked for staging.
# If it does not apply cleanly, you will be given an opportunity to
# edit again.  If all lines of the hunk are removed, then the edit is
# aborted and the hunk is left unchanged.

Правится не файл, а патч, который git потом наложит на индекс. Лишнюю строку с плюсом удаляют. Удаление, которое не нужно, превращают в контекст: минус меняют на пробел. Контекст не трогают: сотри первую или среднюю его строку, и git ответит patch does not apply и спросит Edit again (saying "no" discards!) [y/n]?. Аня удаляет строку с DEBUG, и в индексе оказывается таймаут без отладки:

$ git diff --staged
diff --git a/client.go b/client.go
index 1dabf4c..14ad1b7 100644
--- a/client.go
+++ b/client.go
@@ -3,10 +3,13 @@ package main
 import (
        "fmt"
        "net/http"
+       "time"
 )

+var client = &http.Client{Timeout: 5 * time.Second}
+
 func fetch(url string) (*http.Response, error) {
-       resp, err := http.Get(url)
+       resp, err := client.Get(url)
        if err != nil {
                return nil, fmt.Errorf("fetch %s: %w", url, err)
        }

На диске сверх индекса остались отладка и строка в README. Строка описывает тот же таймаут и едет в тот же коммит, а отладку снимает git restore client.go, копируя файл из индекса на диск. Сообщение набрано в редакторе, о нём следующий раздел.

$ git add README.md
$ git status -s
M  README.md
MM client.go
?? weather
$ git commit
[main 8021ab2] Добавить таймаут HTTP-клиенту
 2 files changed, 5 insertions(+), 1 deletion(-)
$ git restore client.go
$ git status -sb
## main...origin/main [ahead 2]
?? weather
Куски client.go после s дифф индекса и рабочей копии Первый add -p ответы n, n, n, y Второй add -p ответы y, y, e Куда попал итог 1/4 + "time" n пропустить y в индекс 8021ab2 2/4 + var client = &http.Client{…} n пропустить y в индекс 8021ab2 3/4 - resp, err := http.Get(url) + resp, err := client.Get(url) + fmt.Println("DEBUG fetch", url) n пропустить e правка вручную: строка DEBUG удалена 8021ab2 DEBUG не в коммите 4/4 - …Errorf("fecth %s: %w", url, err) + …Errorf("fetch %s: %w", url, err) y в индекс уже в коммите b7e8b34 commit commit main ff79116 Первая версия клиента погоды b7e8b34 Исправить опечатку в тексте ошибки 8021ab2 Добавить таймаут HTTP-клиенту вместе с README.md
Одна правка в файле, два коммита. После s кусков четыре. В первом проходе в индекс идёт только опечатка, во втором импорт, переменная и вызов client.Get. Строку DEBUG из неделимого третьего куска Аня вычёркивает в редакторе: в коммит она не попадает, а потом её стирают с диска.

Сообщение коммита

$ git log -1
commit 8021ab2a9cfda1252ce0c908d1baf537288dc5a7
Author: Аня <anya@example.com>
Date:   Tue Sep 1 12:00:00 2026 +0300

    Добавить таймаут HTTP-клиенту

    http.Get ходит через http.DefaultClient, а у него таймаута нет. Если
    сервис погоды принял соединение и молчит, fetch ждёт ответа сколько
    угодно, и main висит вместе с ним. Отдельный клиент с таймаутом
    в 5 секунд обрывает такой запрос с ошибкой.

От сообщения git требует одного: чтобы оно не было пустым. Остальное соглашения, но на них опираются сами инструменты git. Текст до первой пустой строки считается заголовком: его выводит log --oneline, shortlog перечисляет заголовки под именем автора, не склеивая одинаковые (глава 2.3), а format-patch ставит его в тему письма. Документация git commit советует заголовок до 50 символов. Пустая строка после него нужна, иначе к заголовку приклеится следующая:

$ cat title.txt
Добавить таймаут HTTP-клиенту
и вынести клиент в переменную

Тело.
$ git commit -q -F title.txt
$ git log --oneline -1
57e1ac8 Добавить таймаут HTTP-клиенту и вынести клиент в переменную

В теле пишут то, чего нет в диффе. Что поменялось, дифф покажет и так, а почему, знает автор, и то недолго. SubmittingPatches, правила для авторов патчей в сам git, ставит телу три задачи: объяснить, что не так без правки, почему выбранное решение лучше и какие варианты отвергнуты. Строки переносят около 72 символов, как советует заметка Тима Поупа 2008 года: git log строки не переносит и добавляет отступ в четыре пробела, а из 80 колонок терминала за вычетом четырёх слева и четырёх справа остаётся 72.

Заголовок говорит, что делает коммит, а не что делал автор. По-английски пишут в повелительном наклонении, Add timeout, а не Added: SubmittingPatches просит писать так, будто отдаёшь кодовой базе приказ, и сам git пишет так же: Merge branch …, Revert "…". Для русского у git правил нет. Подойдёт инфинитив, «Добавить таймаут HTTP-клиенту», или настоящее время, «HTTP-клиент обрывает запрос через 5 секунд»; «Добавил таймаут» рассказывает уже об авторе. Форму выбирают одну на весь репозиторий.

У сообщения, набранного в редакторе, есть ловушка. Аня пишет:

Выходить с ошибкой, если сервис ответил 5xx

#412: cron считал запуск успешным,
хотя прогноз не пришёл.
$ git commit      # сообщение набрано в редакторе
[main 38995dc] Выходить с ошибкой, если сервис ответил 5xx
 1 file changed, 1 insertion(+)
$ git log -1 --format=%B
Выходить с ошибкой, если сервис ответил 5xx

хотя прогноз не пришёл.
Строка с решёткой исчезает из сообщения

Шаблон в редакторе сделан из строк, начатых с #, и после закрытия редактора git выбрасывает все такие строки, а с ними и ссылку на задачу. Режим очистки по умолчанию вырезает комментарии, только если сообщение правили в редакторе, поэтому с -m или -F строка уцелеет. В редакторе не начинай строку с # или смени символ комментария настройкой core.commentChar.

Conventional Commits

Спецификация Conventional Commits 1.0.0 размечает сообщение так, чтобы его могла разобрать программа:

<type>[(scope)][!]: <description>

[body]

[footers]

Тип обязателен: feat для новой возможности, fix для исправления. Остальные разрешены; например, @commitlint/config-conventional, выросший из соглашения Angular, предлагает docs, refactor, test, ci, chore и ещё несколько. В скобках указывают область кода. ! перед двоеточием или строка BREAKING CHANGE: … в конце отмечают несовместимое изменение. Завершающие строки (footers) похожи на git trailers: Refs: #412. История клиента в этом стиле, новые сверху:

feat(client)!: fetch принимает context
feat(client): таймаут 5 секунд для HTTP-запросов
fix(client): опечатка в тексте ошибки
В коммитах после релиза v1.4.2Часть semverСледующая версия
только fixPATCHv1.4.3
хотя бы один featMINORv1.5.0
! или BREAKING CHANGE: в любом типеMAJORv2.0.0
только docs, refactor, test и прочиена версию не влияют-

На этом работают release-please и semantic-release: читают коммиты с прошлого релиза, считают следующую версию и собирают из них CHANGELOG. Последняя строка таблицы верна по спецификации, а оба инструмента по умолчанию дают PATCH и на perf, и на revert. Формат проверяет commitlint, в хуке commit-msg (глава 5.3) или в CI. У Go-модуля MAJOR тянет за собой больше, чем тег: с v2 путь модуля обязан кончаться на /v2, а это правка go.mod и всех импортов (глава 4.3).

BREAKING CHANGE с пробелом git трейлером не считает

Ключ трейлера в git не может содержать пробел. Если последний абзац состоит из BREAKING CHANGE: … и Refs: #412, git interpret-trailers --parse не выдаст ничего: блоком трейлеров считается абзац из одних трейлеров или такой, где их хотя бы четверть и есть git-овский, вроде Signed-off-by. Так теряется и Refs. Форму с дефисом, BREAKING-CHANGE:, спецификация считает равноправной, и git видит обе строки. А %(trailers) в git log --format прочитает только её.

commit --amend: новый коммит вместо старого

После коммита Аня написала тест на таймаут и поняла, что место ему в том же коммите:

$ git status -sb
## main...origin/main [ahead 2]
?? client_test.go
?? weather
$ git add client_test.go
$ git commit --amend --no-edit
[main 762c9a8] Добавить таймаут HTTP-клиенту
 Date: Tue Sep 1 12:00:00 2026 +0300
 3 files changed, 14 insertions(+), 1 deletion(-)
 create mode 100644 client_test.go

Хеш у последнего коммита новый. Исправить объект git не может, его имя вычислено из содержимого (глава 1.1). --amend собирает новый коммит: дерево из индекса, как обычно, а родителей, автора и сообщение берёт у текущего (--no-edit принимает сообщение без редактора), и переставляет на него ветку:

$ git cat-file -p 8021ab2 | head -4
tree 5e41332879c8b7445c9385656104435fcc679d07
parent b7e8b34974857cefc6cc9da2d5435eba18f7c5e8
author Аня <anya@example.com> 1788253200 +0300
committer Аня <anya@example.com> 1788253200 +0300
$ git cat-file -p HEAD | head -4
tree ea21ec7b8fd2d1fc3b8c4432d79f50a2054c92ca
parent b7e8b34974857cefc6cc9da2d5435eba18f7c5e8
author Аня <anya@example.com> 1788253200 +0300
committer Аня <anya@example.com> 1788256800 +0300

Родитель тот же, дерево уже с тестом. Время автора осталось от старого коммита, 12:00, коммитер получил 13:00; строку Date: в сводке git печатает, когда автор взят из другого коммита. --reset-author сделал бы автором текущего коммитера.

$ git reflog -3
762c9a8 HEAD@{0}: commit (amend): Добавить таймаут HTTP-клиенту
8021ab2 HEAD@{1}: commit: Добавить таймаут HTTP-клиенту
b7e8b34 HEAD@{2}: commit: Исправить опечатку в тексте ошибки

Старый коммит цел, просто на него не указывает ветка. Reflog помнит его как HEAD@{1}, и пока запись жива, сборка мусора его не тронет (главы 1.4 и 5.2); git reset --soft HEAD@{1} вернул бы ветку назад, оставив тест в индексе (глава 5.1).

Пока 8021ab2 был только у Ани, подмену никто не видел. Теперь ветка уходит на сервер, а следом ещё один amend: в команде приняты Conventional Commits, и Аня переписывает заголовок.

$ git push
To ../origin.git
   ff79116..762c9a8  main -> main
$ git commit --amend     # в редакторе поменяла заголовок
[main 35de720] feat(client): таймаут 5 секунд для HTTP-запросов
 Date: Tue Sep 1 12:00:00 2026 +0300
 3 files changed, 14 insertions(+), 1 deletion(-)
 create mode 100644 client_test.go
$ git status -sb
## main...origin/main [ahead 1, behind 1]
?? weather
$ git push
To ../origin.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to '../origin.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
ff79116 Первая версия клиента погоды b7e8b34 Исправить опечатку HEAD@{2} 8021ab2 без теста, после amend только в reflog origin/main 762c9a8 с client_test.go: amend до push, потом push main 35de720 новый заголовок: amend после push git status -sb после второго amend ## main...origin/main [ahead 1, behind 1] git push ! [rejected] main -> main (non-fast-forward)
Три версии одного коммита. Каждый amend создаёт нового ребёнка у b7e8b34 и переводит на него ветку. Первый вариант держит только reflog, второй уже на сервере, третий в локальной main. Для git это две разошедшиеся ветки, и push отказывает.

762c9a8 и 35de720 для git не отличить от коммитов двух разных людей с общим родителем. Приняв такой push, сервер потерял бы 762c9a8 (глава 4.1).

Подсказка «use git pull» после amend ведёт не туда

Чужих изменений на сервере нет, есть прежняя версия твоего же коммита. git pull со слиянием склеит обе, и в истории окажутся два коммита с одинаковым диффом (пример в ответе на четвёртый вопрос). В своей ветке merge request правильный ход git push --force-with-lease (глава 4.1). Если на ветку опираются другие, опубликованный коммит не переписывают, а исправляют следующим.

--amend дотягивается только до последнего коммита. Для более старого делают git commit --fixup=<коммит>, а rebase с флагом --autosquash вклеивает исправление на место (глава 3.3).

Вопросы

4
Суть: git commit записывает индекс, а не диск. В git status -s первая колонка показывает добавленное, вторая то, что осталось на диске. Построчно будущий коммит показывает git diff --staged или дифф в редакторе git commit -v. Новый файл без add в коммит не попадёт даже с -a.

Что именно записывает commit

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

$ git status -s
 M README.md
M  client.go
$ git commit -m "Описать запуск в README" README.md
[main f974042] Описать запуск в README
 1 file changed, 1 insertion(+)
$ git status -s
M  client.go

Проверки

Сначала git status -sb: первая колонка уйдёт в коммит, вторая останется на диске, ?? напоминает про новые файлы. Потом git diff --staged, ровно то, что будет записано. Пустой git diff про коммит ничего не говорит, он сравнивает индекс с диском (глава 1.3).

На чём ошибаются

git add . забирает всё неигнорируемое: бинарь от go build, локальный .env; заранее это покажет git add --dry-run .. Обратная ошибка: положиться на commit -a и забыть новый файл. У автора всё собирается, а в CI нет.

Собирается рабочая копия, а не коммит

После add -p на диске больше, чем в индексе, и зелёный go test проверил не то, что уйдёт в историю. Сам индекс проверяют через git stash push --keep-index: на диске остаётся версия из индекса, а в записи и индекс, и диск. Вернуть правки надёжнее через git reset --hard && git stash pop --index: если добавленная и недобавленная правки соседние, обычный pop даст конфликт (глава 2.2).

Суть: через git add -p. Git показывает дифф кусками: y добавляет кусок, n пропускает, s режет по неизменённым строкам. Если правки стоят вплотную, e открывает кусок в редакторе, где лишние плюсы удаляют, а лишний минус меняют на пробел. Потом git diff --staged, коммит и следующий проход.

Порядок

Сначала решают, какие коммиты должны получиться. Проходов add -p столько же: в каждом в индекс идут куски одного изменения, затем git diff --staged, сборка и коммит. Лишнее вынимают из индекса по кускам через git reset -p, а если файл уже добавлен целиком, git restore --staged файл вернёт индекс к HEAD.

Когда s не делит

s режет только там, где между правками есть неизменённая строка. Замена вызова и отладочная печать сразу под ней остаются одним куском, и буквы s в приглашении нет. Тогда e: в редакторе открывается патч. Лишние плюсы удаляют, минус у строки, которая должна остаться, меняют на пробел, контекст не трогают. Не лёг патч, git спросит, открыть ли редактор снова.

Новый файл

Неотслеживаемого файла нет в индексе, и add -p его не видит. Нужна запись-намерение git add -N (глава 1.3):

$ git add -p new.go
No changes.
$ git add -N new.go
$ git add -p new.go     # дифф нового файла пропущен
(1/1) Stage addition [y,n,q,a,d,e,p,P,?]? y

Так же по кускам работают git commit -p, git restore -p (выбросить куски с диска) и git stash push -p.

Правка в e может добавить то, чего нет на диске

Патч из e ложится только на индекс. Допиши в него строку, и в индекс уйдёт код, которого нет в рабочей копии: git diff покажет его как откаченный, и собирать его никто не будет. Документация git add советует такого избегать: в e только удаляй плюсы и превращай минусы в контекст.

Суть: короткий заголовок, около 50 символов, пустая строка и тело о том, зачем нужна правка. Заголовок описывает изменение, а не действия автора. Conventional Commits ставят в начало заголовка тип, и по типам инструменты считают следующую версию: fix даёт PATCH, feat MINOR, ! или BREAKING CHANGE: MAJOR.

Заголовок и тело

Заголовок попадает в log --oneline, shortlog и тему письма у format-patch, поэтому должен читаться сам по себе: «Добавить таймаут HTTP-клиенту», а не «fix» или «правки». От тела его отделяет пустая строка, иначе git склеит заголовок со второй строкой. В теле объясняют, зачем правка: что было не так без неё и почему выбрано это решение. Скажем, что http.DefaultClient ждёт ответа без ограничения по времени. В диффе этого не видно, а через git blame к этому тексту придут через год. Строки переносят около 72 символов, потому что git log сам их не переносит и добавляет отступ.

Conventional Commits

Формат тип(область)!: описание, дальше тело и завершающие строки вроде Refs: #412. После релиза v1.4.2 одни fix дадут v1.4.3, хотя бы один feat даст v1.5.0, а feat(client)!: fetch принимает context даст v2.0.0. Остальные типы разрешены и по спецификации на версию не влияют. Команда получает CHANGELOG и номер версии без ручной работы (release-please, semantic-release), а commitlint не пропустит сообщение не по формату. Цена в том, что коммит должен укладываться в один тип; спецификация так и советует: подходит под два, разбей на два коммита.

На чём ловят

  • Строка #412: … пропала из сообщения, набранного в редакторе: git выбросил её вместе с комментариями шаблона. С -m и -F такого не бывает.
  • В Go-библиотеке feat! не сводится к тегу v2.0.0: путь модуля должен получить суффикс /v2 (глава 4.3).
«Правки по ревью» не сообщение

Коммиты «fix», «wip», «правки по ревью» пишут про процесс, а не про изменение. В ветке merge request их вклеивают в исходные коммиты через --fixup и rebase --autosquash или сливают ветку squash-слиянием (глава 3.3). Попав в основную ветку, они остаются навсегда.

Суть: amend не правит последний коммит, а создаёт новый: дерево из индекса, родители, автор и сообщение от прежнего. Ветка переходит на новый коммит, старый остаётся в reflog. До push это безопасно. После push локальная ветка и сервер расходятся: push отказывает, а pull со слиянием склеит обе версии.

Что происходит

По документации это примерно git reset --soft HEAD^ и коммит с сообщением из ORIG_HEAD, только amend умеет поправить и merge-коммит. Хеш меняется, потому что меняется хоть что-то: дерево, сообщение или время коммитера. Совпало всё до секунды, и объект получится тот же (время здесь закреплено переменными окружения):

$ git commit --amend --no-edit -q
$ git reflog -2
933e609 HEAD@{0}: commit (amend): Комментарий
933e609 HEAD@{1}: commit: Комментарий

Amend забирает всё, что сейчас в индексе, в том числе добавленное случайно. Чтобы поменять только сообщение и не трогать индекс, есть git commit --amend --only.

После push

Коммит уже на сервере, а amend создаёт ему замену: git status -sb показывает [ahead 1, behind 1], push отклонён как non-fast-forward. Если поверить подсказке и слить:

$ git pull --no-rebase
Merge made by the 'ort' strategy.
$ git log --oneline --graph
*   f25e023 Merge branch 'main' of ../origin
|\
| * 762c9a8 Добавить таймаут HTTP-клиенту
* | 35de720 feat(client): таймаут 5 секунд для HTTP-запросов
|/
* b7e8b34 Исправить опечатку в тексте ошибки
* ff79116 Первая версия клиента погоды
$ git diff HEAD^1 HEAD

Одна правка дважды в истории под двумя заголовками, плюс merge-коммит, который ничего не меняет: дифф с первым родителем пуст. Без флага и без настройки pull.rebase git 2.54 такой pull вообще не выполнит и попросит выбрать способ (глава 4.1).

Как правильно

  • Коммит не отправлен: amend свободно.
  • Отправлен в свою ветку merge request: amend и git push --force-with-lease, который откажет, если ветка на сервере сдвинулась с тех пор, как ты её видел (глава 4.1).
  • Отправлен в общую ветку: новый коммит или git revert (глава 5.1).
  • Нужен не последний коммит: --fixup и rebase с --autosquash (глава 3.3).
  • Amend по ошибке: git reset --soft HEAD@{1} (глава 5.2).
«Я только поправил сообщение»

Сообщение входит в содержимое коммита наравне с деревом и родителями. Исправленная опечатка в заголовке даёт новый хеш, и опубликованный коммит расходится с серверным так же, как после правки кода.

Что стоит запомнить

  • Commit записывает index, а не все файлы рабочего каталога.
  • git add -p позволяет выбрать отдельные hunks и разделить коммит по смыслу.
  • Amend создаёт новый commit object с новым hash.

Частые ошибки

  • Не делай amend опубликованного коммита без согласования переписывания общей истории.
  • Успешный commit не доказывает, что в index попали только нужные файлы.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай status -sb

Что означает состояние MM у файла и какая версия окажется в следующем commit?

## feature/login...origin/feature/login [ahead 1]
MM internal/auth.go
Ответ и разбор

Первая M означает, что содержимое файла в index отличается от HEAD; вторая M - что рабочая копия отличается от index. Следующий commit запишет только staged-версию. Строка ветки говорит, что локальная ветка на один commit впереди origin tracking branch.

Код-ревью

2. Не переписывай уже опубликованный commit

Исправление комментария уже отправлено в общую ветку. В инструкции предлагают:

git commit --amend
git push --force

Как исправить работу без переписывания общей истории?

Ответ и разбор

Amend создаёт новый commit с другим hash, а force push переписывает указатель ветки и может убрать чужие изменения. Если commit уже опубликован, создай новый commit с исправлением. Если переписывание действительно нужно, сначала согласуй его и используй защиту от затирания новых remote commits.

Применение

3. Раздели правку на два коммита

Один файл содержит независимые изменения: исправление обработки таймаута и форматирование комментариев. Как собрать их в два коммита, если hunks можно разделить?

Ответ и разбор

Используй git add -p file, добавь hunk таймаута и проверь git diff --staged, затем создай первый commit. Добавь оставшийся hunk и создай второй. Если один hunk смешивает обе правки, сначала раздели его интерактивно через split, затем проверь итоговый diff.

Применение

4. Проверь Conventional Commit

Изменение исправляет ошибку в повторных запросах, но не добавляет функцию. Какое сообщение Conventional Commits передаст тип изменения и почему?

Ответ и разбор

fix(retry): stop after request deadline обозначает исправление и область retry. Тип fix обычно сигнализирует patch-релиз в SemVer-инструментах, если проект настроил такую связь. Сам формат сообщения не меняет версию автоматически без release-конфигурации.