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
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 | Следующая версия |
|---|---|---|
только fix | PATCH | v1.4.3 |
хотя бы один feat | MINOR | v1.5.0 |
! или BREAKING CHANGE: в любом типе | MAJOR | v2.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).
Ключ трейлера в 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.
b7e8b34 и переводит на него ветку. Первый вариант держит только reflog, второй уже на
сервере, третий в локальной main. Для git это две разошедшиеся ветки, и push
отказывает.
762c9a8 и 35de720 для git не отличить от коммитов двух разных людей с общим
родителем. Приняв такой push, сервер потерял бы 762c9a8 (глава 4.1).
Чужих изменений на сервере нет, есть прежняя версия твоего же коммита. git pull со
слиянием склеит обе, и в истории окажутся два коммита с одинаковым диффом (пример в ответе на
четвёртый вопрос). В своей ветке merge request правильный ход git push --force-with-lease
(глава 4.1). Если на ветку опираются другие, опубликованный коммит не переписывают, а исправляют
следующим.
--amend дотягивается только до последнего коммита. Для более старого делают
git commit --fixup=<коммит>, а rebase с флагом
--autosquash вклеивает исправление на место (глава 3.3).
Вопросы
4git 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 ложится только на индекс. Допиши в него строку, и в индекс уйдёт код,
которого нет в рабочей копии: git diff покажет его как откаченный, и собирать его
никто не будет. Документация git add советует такого избегать: в e
только удаляй плюсы и превращай минусы в контекст.
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). Попав в основную
ветку, они остаются навсегда.
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-конфигурации.