Повседневная работа
Каждый день с git делают три вещи: собирают коммиты, наводят порядок в рабочей копии и читают
историю. Коммит начинается раньше команды commit: индекс собирают по кускам через
add -p, правки раскладывают на атомарные коммиты, сообщение пишут так, чтобы его поняли
через год. А --amend применяют с оглядкой на push. В рабочей копии хозяйничают
switch и restore, которые в git 2.23 разделили обязанности перегруженного
checkout, а рядом stash, .gitignore и
.gitattributes, от которого зависят переводы строк. Историю читают фильтрами
log, через blame, который умеет не замечать коммит с прогоном gofmt, и через
log -S, когда надо найти, где строка появилась или исчезла.
Оставляешь ли ты после себя историю, с которой можно работать. Коммит «fix», где вперемешку таймаут, опечатка и отладочная печать, не откатить по частям и не объяснить на ревью. Вопросы практические. Как закоммитить половину файла, что делать с отправленным коммитом, в котором ошибка, как отложить незаконченную работу ради срочного бага, как найти, кто и зачем поменял строку. Уверенно отвечает тот, кто знает, где после каждой команды оказываются правки: в индексе, на диске, в stash или в новом коммите.
2.1Коммит
Коммит записывает индекс, поэтому хороший коммит собирают ещё до git commit: смотрят,
что уже лежит в индексе, добирают нужные куски файла, а лишние оставляют на диске. Потом пишут
сообщение, которое через год объяснит, зачем понадобилась правка. Последний коммит можно
пересобрать через --amend, но получится другой коммит, и после push это заметят все,
у кого остался старый.
- Что значат две буквы перед именем файла в
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) режет дифф индекса и рабочей копии на куски, hunks, и про каждый
спрашивает, добавлять ли его. Правки, между которыми не больше шести неизменённых строк, попадают в
один кусок: три строки контекста вокруг каждой смыкаются. У Ани кусок получился один (вывод 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).
Сообщение входит в содержимое коммита наравне с деревом и родителями. Исправленная опечатка в заголовке даёт новый хеш, и опубликованный коммит расходится с серверным так же, как после правки кода.
2.2Рабочая копия
Недобавленная правка лежит только на диске, копии у git нет. Одни команды перезаписывают такие
файлы без вопросов, другие превращают правки в коммиты, чтобы их можно было отложить. А
.gitignore и .gitattributes решают, какие файлы git замечает и с какими
концами строк пишет их на диск.
- Зачем checkout разделили на
switchиrestoreи откудаrestoreберёт версию файла. - Что сохраняет
git stash, из каких коммитов состоит запись и почему после конфликта она остаётся в списке. - Почему правило в
.gitignoreне срабатывает или задевает лишнее и как найти строку, которая скрыла файл. - Откуда в диффе «изменены все строки» и как
.gitattributesлечит это у всей команды.
checkout: две команды под одним именем
С именем ветки git checkout переключает HEAD и переписывает индекс с рабочей копией, а
если мешает незакоммиченная правка, отказывает (глава 1.3). С путём он копирует файл из индекса
поверх рабочей копии и ничего не спрашивает. Что делать, git выбирает по аргументу. У Ани в клоне
поправлен handler.go, в проекте есть каталог docs, а на сервере ветка
docs:
$ git status --short
M handler.go
$ git checkout docs # каталог или ветка?
fatal: 'docs' could be both a local file and a tracking branch.
Please use -- (and optionally --no-guess) to disambiguate
$ git checkout feture # опечатка в имени ветки
error: pathspec 'feture' did not match any file(s) known to git
$ git checkout .
Updated 1 path from the index
$ git status --short
Опечатка в имени ветки превратилась в путь, а точка, то есть все файлы каталога, молча выбросила
правку. В git 2.23 (2019) из checkout выделили две узкие команды: git switch работает
только с ветками, git restore только с файлами. До 2.51 обе числились
экспериментальными. checkout не объявлен устаревшим, но подсказки git status предлагают
уже restore. Чужой аргумент новые команды не примут:
$ git switch docs
M handler.go
branch 'docs' set up to track 'origin/docs'.
Switched to a new branch 'docs'
$ git switch feture
fatal: invalid reference: feture
$ git switch v0.1.0
fatal: a branch is expected, got tag 'v0.1.0'
hint: If you want to detach HEAD at the commit, try again with the --detach option.
$ git restore main
error: pathspec 'main' did not match any file(s) known to git
На тег или коммит switch без --detach не встанет, и в detached HEAD (глава 1.2)
случайно не попасть. Старые формы и новые:
| Было | Стало | Что происходит |
|---|---|---|
git checkout -b fix | git switch -c fix | создать ветку и перейти |
git checkout v0.1.0 | git switch --detach v0.1.0 | detached HEAD на теге |
git checkout --orphan site | git switch --orphan site | ветка без истории; switch, в отличие от checkout, очищает индекс и диск |
git checkout -- main.go | git restore main.go | файл из индекса на диск |
git checkout docs -- api.md | git restore -s docs -SW api.md | файл из другой ветки в индекс и на диск |
git reset -- main.go | git restore --staged main.go | убрать правку из индекса |
restore: откуда и куда
Куда писать, задают флаги: -W (--worktree, по умолчанию) пишет в рабочую
копию, -S (--staged) в индекс, оба сразу пишут в оба места. Откуда брать,
задаёт -s (--source): коммит, ветка или тег. Без него рабочая копия
восстанавливается из индекса, а индекс из HEAD.
| Команда | Откуда | Куда |
|---|---|---|
git restore f | индекс | рабочая копия |
git restore --staged f | HEAD | индекс |
git restore -SW f | HEAD | индекс и рабочая копия |
git restore -s HEAD~2 f | коммит HEAD~2 | рабочая копия |
У handler.go три версии: в HEAD, в индексе с третьим заказом и на диске с дописанным
четвёртым.
$ git status --short
MM handler.go
$ git restore handler.go # диск ← индекс: четвёртый заказ пропал
$ git status --short
M handler.go
$ git restore --staged handler.go # индекс ← HEAD: правка осталась на диске
$ git status --short
M handler.go
С --source restore пишет только на диск, пока не попросишь --staged, а
git checkout коммит -- путь пишет сразу в индекс и на диск. В истории Ани три коммита:
«Сервис заказов» с портом 8080, «Порт 8081» и «Описание API».
$ git restore --source=HEAD~2 main.go
$ git status --short
M main.go
$ git restore main.go
$ git checkout HEAD~2 -- main.go
$ git status --short
M main.go
Второе отличие видно на файлах, которых в источнике нет. checkout работает в режиме overlay
(«наложение»): кладёт файлы коммита поверх текущих и ничего не удаляет. restore приводит пути к
источнику в точности. В HEAD~1 ещё нет docs/api.md:
$ git checkout HEAD~1 -- .
$ git status --short
$ git restore --source=HEAD~1 .
$ git status --short
D docs/api.md
Переключают режим флаги --no-overlay у checkout и --overlay у restore.
Правку, которую ни разу не добавляли в индекс, git не хранит нигде, кроме самого файла. После
git restore или git checkout . её нет ни в объектах, ни в reflog, и
git fsck (глава 5.2) её не найдёт. Сомневаешься, сначала git stash. А
git restore -p показывает фрагменты по одному и про каждый спрашивает
Discard this hunk from worktree.
stash: отложить незаконченное
У Ани в индексе третий заказ в handler.go, в main.go вызов ещё не написанной
обёртки withLog, рядом новый cache.go и собранный server из
.gitignore. Просят срочно поправить main. stash («тайник»)
сохраняет незакоммиченные правки в отдельную запись и возвращает индекс и рабочую копию к HEAD:
$ git status --short --ignored
M handler.go
M main.go
?? cache.go
!! server
$ git stash
Saved working directory and index state WIP on main: 7b653aa Описание API
$ git status --short --ignored
?? cache.go
!! server
$ git stash list
stash@{0}: WIP on main: 7b653aa Описание API
$ git stash pop -q # срочное сделано, возвращаем правки
$ git status --short
M handler.go
M main.go
?? cache.go
Два сюрприза. Без флагов stash берёт только отслеживаемые файлы: cache.go остался на
диске и уехал бы на любую ветку. И handler.go после pop больше не в
индексе: снимок индекса в записи есть, но без --index все правки выкладываются на диск.
$ git add handler.go
$ git stash -u
Saved working directory and index state WIP on main: 7b653aa Описание API
$ git status --short --ignored
!! server
$ git stash pop -q --index
$ git status --short --ignored
M handler.go
M main.go
?? cache.go
!! server
| Вызов | Правки отслеживаемых | Неотслеживаемые | Игнорируемые |
|---|---|---|---|
git stash | в запись | остаются на диске | остаются |
git stash -u | в запись | в запись, с диска удаляются | остаются |
git stash -a | в запись | в запись | в запись, с диска удаляются |
git stash --keep-index | в запись, индекс остаётся на месте | остаются | остаются |
git stash --staged (2.35) | в запись только индекс | остаются | остаются |
--keep-index нужен, чтобы проверить будущий коммит без остальных правок.
go vet падает на недописанном withLog, а третий заказ от него не зависит:
$ go vet ./...
# example.com/shop
# [example.com/shop]
vet: ./main.go:9:29: undefined: withLog
$ git stash --keep-index -u
Saved working directory and index state WIP on main: 7b653aa Описание API
$ git status --short
M handler.go
$ go vet ./... && git commit -qm "Третий заказ"
$ git stash pop -q
$ git status --short
M main.go
?? cache.go
На диске остался ровно будущий коммит, он проверен и создан, а pop вернул остальное.
Такой цикл есть в документации git stash. Но будь отложенная правка соседней с
закоммиченной, pop кончился бы конфликтом, как в разделе ниже.
Как устроена запись stash
$ git stash -u
Saved working directory and index state WIP on main: 7b653aa Описание API
$ git log --graph --oneline stash@{0}
*-. bd2d11e WIP on main: 7b653aa Описание API
|\ \
| | * 16ec9ab untracked files on main: 7b653aa Описание API
| * 6b52fe1 index on main: 7b653aa Описание API
|/
* 7b653aa Описание API
* e64310b Порт 8081
* c1ed37a Сервис заказов
$ git cat-file -p stash@{0}
tree 1c999bf0231ae9a8b809a9c3f91c733a2d685e4f
parent 7b653aa7115b9f51d13bb9901c84c99f6e7a1653
parent 6b52fe1370a8233b368ba31bfb6e9339b3578ce6
parent 16ec9abc9e3d4260515514de38becb7dd5f8ef64
author Аня <anya@example.com> 1788256800 +0300
committer Аня <anya@example.com> 1788256800 +0300
WIP on main: 7b653aa Описание API
$ git ls-tree stash@{0}^3
100644 blob 2ce54467ebfe2864ad9435e8611d0715c1659cf5 cache.go
Запись собрана из обычных коммитов. refs/stash указывает на bd2d11e
(в документации W) с тремя родителями, а его дерево хранит отслеживаемые файлы в том виде, в каком
они лежали на диске. Первый родитель, HEAD в момент stash, служит базой при возврате. Второй,
6b52fe1 (I), хранит снимок индекса, по нему работает pop --index. Третий,
16ec9ab (U), появляется только с -u или -a: в его дереве одни
неотслеживаемые файлы, родителей у него нет.
git stash -u.
Справа то, что записала команда: коммит индекса, коммит неотслеживаемых файлов без родителя и
коммит рабочей копии с тремя родителями, на который указывает refs/stash.
Список записей хранится в reflog ссылки refs/stash, отсюда имена
stash@{1} в синтаксисе reflog из главы 1.2. Спрячем ещё правку в
docs/api.md:
$ git stash -m "черновик POST"
Saved working directory and index state On main: черновик POST
$ git reflog show --format='%h %gd %gs' refs/stash
5af9976 stash@{0} On main: черновик POST
bd2d11e stash@{1} WIP on main: 7b653aa Описание API
Stash локален: refs/stash не ветка, push её не отправляет, clone не приносит. С git
2.51 записи переносят так: git stash export --to-ref refs/stashes/anya собирает их в
цепочку коммитов, её пушат, а на другой машине загружают через git stash import.
Запись git stash drop убирает только из reflog, коммиты остаются: по хешу из строки
Dropped refs/stash@{0} (bd2d11e…) команда git stash apply --index bd2d11e
вернёт правки, пока их не убрала сборка мусора (глава 5.2). А возврат записи устроен как слияние, и
у слияния бывают конфликты.
Когда pop оставляет запись
pop делает apply и drop, но drop выполняет, только если apply
прошёл чисто. Пока запись лежала, в main появился коммит с заказом B-7 в той же строке:
$ git log --oneline -1
1dbdad2 Заказ B-7
$ git stash pop # сводка status из вывода убрана
Auto-merging handler.go
CONFLICT (content): Merge conflict in handler.go
The stash entry is kept in case you need it again.
$ git status --short
UU handler.go
M main.go
?? cache.go
$ git stash list
stash@{0}: WIP on main: 7b653aa Описание API
$ sed -n 9,13p handler.go
<<<<<<< Updated upstream
orders := []string{"A-1", "B-7"}
=======
orders := []string{"A-1", "A-2", "A-3"}
>>>>>>> Stashed changes
apply провёл трёхстороннее слияние (глава 3.1) с базой 7b653aa: с одной стороны текущий
HEAD, с другой дерево W. Маркеры подписаны Updated upstream (ветка) и
Stashed changes (запись). Выброси git запись сейчас, и неудачный разбор конфликта
оставил бы тебя без целой копии правок.
Заметь M main.go: чисто применённая правка оказалась в индексе, хотя
пряталась недобавленной. Так бывает при любом слиянии с конфликтом (глава 3.2). Оставляем в
handler.go оба заказа, чистим индекс и удаляем запись сами:
$ git restore --staged .
$ git status --short
M handler.go
M main.go
?? cache.go
$ git stash drop
Dropped refs/stash@{0} (bd2d11e5b19f1ee7db809633fde7e98e8681d865)
Разбирать конфликт не обязательно. git stash branch создаёт ветку от первого родителя
записи, где правки и делались, применяет их вместе с индексом и при успехе удаляет запись. База
совпадает, конфликту взяться неоткуда:
$ git stash branch orders-a3 # сводка status из вывода убрана
Switched to a new branch 'orders-a3'
Dropped refs/stash@{0} (bd2d11e5b19f1ee7db809633fde7e98e8681d865)
$ git status --short
M handler.go
M main.go
?? cache.go
$ git log --oneline -1
7b653aa Описание API
.gitignore: как читаются шаблоны
Шаблон без слеша совпадает с именем на любой глубине. Слеш в начале или в середине привязывает его
к каталогу, где лежит .gitignore, слеш в конце оставляет только каталоги. Звёздочка не
проходит через /, а ** проходит.
| Шаблон | Что игнорирует |
|---|---|
server | server на любой глубине, в том числе каталог cmd/server/ |
/server | только server рядом с .gitignore |
*.test | бинарники go test -c вроде shop.test в любом каталоге |
logs/ | каталоги logs, файл с таким именем не задевает |
docs/*.md | docs/api.md, но не docs/v1/api.md |
docs/**/*.md | оба: /**/ совпадает с любым числом каталогов, и с нулём тоже |
!logs/.gitkeep | отменяет игнор, если сам каталог logs не исключён |
Первая строка таблицы подводит Go-проекты чаще остальных. Бинарник когда-то собирали в корне и
внесли в игнор без слеша, а теперь Аня переносит main.go в cmd/server/:
$ cat .gitignore
server
$ mkdir -p cmd/server
$ mv main.go cmd/server/main.go
$ git add .
$ git status --short
D main.go
$ git check-ignore -v cmd/server/main.go
.gitignore:1:server cmd/server/main.go
$ echo /server > .gitignore
$ git add .
$ git status --short
M .gitignore
R main.go -> cmd/server/main.go
git add . молча пропустил каталог, и в коммит ушло бы одно удаление.
git check-ignore -v называет файл правил, номер строки и сработавший шаблон. На
! попадаются тоже часто: в исключённый каталог git не заглядывает, и отрицание для
файла внутри не действует. Исключать надо содержимое каталога:
$ printf '/server\nlogs/\n!logs/.gitkeep\n' > .gitignore
$ git check-ignore -v logs/.gitkeep
.gitignore:2:logs/ logs/.gitkeep
$ printf '/server\nlogs/*\n!logs/.gitkeep\n' > .gitignore
$ git check-ignore -v logs/.gitkeep logs/app.log
.gitignore:3:!logs/.gitkeep logs/.gitkeep
.gitignore:2:logs/* logs/app.log
$ git status --short --ignored logs
?? logs/
!! logs/app.log
Совпавший шаблон с ! значит, что файл виден. status --ignored помечает
скрытое через !!, так удобно проверять, не спрятано ли лишнее.
Где ещё живут правила игнора
Источники по старшинству: командная строка, .gitignore в каталоге файла и выше (нижний
перекрывает верхний), .git/info/exclude, файл из core.excludesFile. Внутри
уровня решает последний совпавший шаблон. .gitignore коммитится и действует у всех.
В .git/info/exclude держат личное для одного репозитория, он никуда не уезжает. А
core.excludesFile общий для всех твоих репозиториев, по умолчанию это
$XDG_CONFIG_HOME/git/ignore или ~/.config/git/ignore: место для
.DS_Store и .idea/.
$ git config get core.excludesFile # не задан: действует ~/.config/git/ignore
$ mkdir -p ~/.config/git
$ printf '.DS_Store\n.idea/\n*.log\n' > ~/.config/git/ignore
$ echo 'notes.md' >> .git/info/exclude
$ echo '!audit.log' >> .gitignore
$ touch notes.md debug.log audit.log
$ git status --short
M .gitignore
?? audit.log
$ git check-ignore -v notes.md audit.log
.git/info/exclude:7:notes.md notes.md
.gitignore:2:!audit.log audit.log
debug.log скрыл глобальный *.log, а audit.log вернула строка в
старшем .gitignore. Для глобального файла check-ignore -v печатает полный
путь. Правило в exclude на строке 7, потому что первые шесть git записал комментариями
при создании репозитория.
На отслеживаемые файлы игнор не действует: git видит их всегда, check-ignore о них
молчит, а снять с учёта может только git rm --cached (глава 1.3). По той же причине
файл, добавленный через git add -f, дальше отслеживается как обычный. Игнорируемое git
считает расходным: switch перезапишет его молча (глава 1.3), stash -u не
сохранит, git clean -fdX удалит, а git clean -ndX заранее покажет, что именно.
.gitattributes: концы строк
Редакторы на Windows заканчивают строку байтами CR LF, на Linux и macOS одним LF. Без правил git
кладёт в блоб байты как есть. У Бори на Windows core.autocrlf выключен, и редактор
сохранил handler.go с CRLF после правки одной строки:
$ git diff --stat
handler.go | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
$ git diff --stat --ignore-cr-at-eol
handler.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git ls-files --eol handler.go
i/lf w/crlf attr/ handler.go
Колонки ls-files --eol: i/ в индексе, w/ на диске,
attr/ действующие атрибуты. После коммита Бори у Ани в репозитории
i/crlf, в истории правка на все 11 строк, и blame приписывает Боре весь файл.
Обычно советуют core.autocrlf=true. С ним git переводит CRLF в LF при
add и LF в CRLF при записи на диск, и установщик Git for Windows ставит его по
умолчанию. Но это настройка машины, в репозиторий она не попадает. Клон с ней (преобразование от
системы не зависит, пример воспроизводится и на macOS):
$ git clone -q -c core.autocrlf=true shop.git win
$ cd win
$ git ls-files --eol handler.go main.go
i/lf w/crlf attr/ handler.go
i/lf w/crlf attr/ main.go
$ gofmt -l .
handler.go
main.go
$ git status --short
$ git config core.autocrlf false
$ git status --short
$ touch handler.go # сменилось только время изменения
$ git status --short
M handler.go
gofmt пишет только LF и считает каждый файл с CRLF неотформатированным. Выключил настройку, и git молчит: время изменения и размер файлов прежние, содержимое он не перечитывает. Файл всплывает изменённым целиком, когда его тронут. Сразу после clone изменёнными покажутся все: индекс записан в ту же секунду, что и файлы, поэтому git сверяет их содержимое. А тот, кто настройку не включал, так и коммитит CRLF.
Лечат это атрибуты. .gitattributes коммитится, и правила одинаковы в каждом клоне.
Шаблоны в нём пишутся как в .gitignore, но каталог целиком задают как api/**:
слеш в конце на файлы внутри здесь не действует. text=auto велит git самому
отличать текст от двоичного и хранить текст с LF. eol=lf задаёт концы строк на диске и
перекрывает core.autocrlf; автоопределение он не отключает с git 2.10. Файлы, уже
закоммиченные с CRLF, git при этом не трогает, их переписывает
git add --renormalize . (git 2.16):
$ echo '* text=auto eol=lf' > .gitattributes
$ git add .gitattributes
$ git add --renormalize .
$ git status --short
A .gitattributes
M handler.go
$ git commit -qm "Хранить концы строк как LF"
$ git ls-files --eol handler.go
i/lf w/crlf attr/text=auto eol=lf handler.go
На диске остался старый файл. status чист, ведь после преобразования содержимое
совпадает с индексом, и git restore его не перепишет. Так же в клоне с
autocrlf после pull: пришедшие файлы получат LF, прочие останутся с CRLF.
На чистой рабочей копии все файлы перепишут git rm -r -q --cached . и
git restore --staged --worktree ., после чего gofmt -l молчит.
core.autocrlf помогает только тому, кто его включил, а правило в
.gitattributes действует в каждом клоне.
В корневом .gitattributes проекта Go одна строка * -text: файлы хранятся
побайтно. Комментарий над ней объясняет выбор: результат одинаков в любом окружении, от
контрибьюторов на Windows ждут редактор с LF, а случайный CRLF ловят проверки gofmt в
git-codereview. Обычному проекту без таких проверок проще * text=auto eol=lf.
.gitattributes для Go: diff=golang и сгенерированный код
В строке @@ после номеров строк стоит заголовок ханка. По умолчанию git берёт ближайшую
выше строку, которая начинается с буквы, _ или $. В Go это подводит на
многострочном SQL в обратных кавычках. С git 2.17 есть встроенный драйвер golang: он
ищет строки с func и объявления type … struct или interface.
$ git diff | grep '^@@'
@@ -25,7 +25,7 @@ WHERE user_id = $1`, userID)
$ echo '*.go diff=golang' > .gitattributes
$ git diff | grep '^@@'
@@ -25,7 +25,7 @@ func (s *Store) ListOrders(ctx context.Context, userID int64) ([]string, error)
$ git diff --word-diff | grep '\[-'
ids = append(ids, [-id-]{+"order-"+id+})
Драйвер знает и токены Go: без него --word-diff режет по пробелам и выдаёт
[-id)-]{+"order-"+id)+}. На него же опирается git log -L :ListOrders:store.go
(глава 2.3): без драйвера функция обрывается на строке SELECT id, и git следит за двумя
строками вместо двадцати.
Атрибуты читают и серверы. С linguist-generated GitHub прячет файл в диффе, пока его не
развернуть, и не считает в статистике языков. GitLab так же сворачивает файлы с
gitlab-generated. Go-файл со строкой // Code generated … DO NOT EDIT.
(соглашение из go help generate) GitHub узнаёт сам, GitLab только по словам «Code
generated by». Остальному сгенерированному, например спецификации OpenAPI, нужен атрибут. Что действует на путь, покажет git check-attr -a путь.
Отрицания ! здесь запрещены, вместо них ставят минус: -linguist-generated.
# концы строк: в репозитории и на диске LF
* text=auto eol=lf
*.bat text eol=crlf
*.png binary
# заголовки ханков, --word-diff и log -L для Go
*.go diff=golang
# сгенерированное: свернуть в ревью на GitHub и GitLab
api/openapi.gen.yaml linguist-generated gitlab-generated
binary встроенный макрос для -diff -merge -text: такой файл git не сравнивает
построчно, не сливает и не преобразует. Конфликты в go.sum и сгенерированном коде
разобраны в главе 3.2.
Вопросы
4git restore файл, копия из
индекса на диск. Добавленные снимает git restore --staged файл, копия из HEAD в индекс,
диск не трогается. Файл из прошлого достают с --source. Необратимо всё, что
перезаписывает рабочую копию: недобавленная правка нигде, кроме диска, не хранилась.Какая команда
| Что хочешь | Команда | Статус до → после |
|---|---|---|
| выбросить недобавленное | git restore f | MM → M |
| снять с индекса, оставив на диске | git restore --staged f | M → M |
| вернуть файл к HEAD целиком | git restore -SW f | MM → чисто |
| взять файл из старого коммита | git restore -s HEAD~2 f | чисто → M, с -SW → M |
Новый файл после --staged снова неотслеживаемый, с диска он не пропадает:
$ git add store.go
$ git status --short
A store.go
$ git restore --staged store.go
$ git status --short
?? store.go
Старые формы и поиск версии
То же делают git checkout -- f, git checkout коммит -- f и
git reset -- f, но checkout коммит -- f пишет сразу в индекс и на диск, а
для каталога не удаляет файлы, которых в коммите нет. Коммит ищут через
git log --oneline -- f (глава 2.3), посмотреть версию без записи на диск можно так:
git show HEAD~2:main.go. Удалённый коммитом файл вернёт
git restore -s коммит -SW путь; без -SW он появится неотслеживаемым.
Что не вернуть
git restore f, git restore -SW f и git checkout . затирают
файлы без подтверждения. Если затёртая версия ни разу не проходила через git add,
объекта с ней нет, и ни reflog, ни fsck не помогут. Если проходила, в базе остался
блоб без ссылок, и его можно выловить (глава 5.2).
git stash возвращает файлы к HEAD так же, как git restore -SW ., но
сохраняет правки коммитами. Не понадобились, удаляешь запись git stash drop, и даже
тогда коммиты живут до сборки мусора.
-u, игнорируемые только с -a. pop выполняет
apply и drop, но drop пропускает, если apply закончился конфликтом или
ошибкой: запись остаётся единственной целой копией правок.Что попадает в запись
Коммит W, на который указывает refs/stash, хранит отслеживаемые файлы с диска. Его
второй родитель I хранит индекс, третий U появляется с -u или -a и
хранит неотслеживаемые файлы. Индекс сохраняется всегда, но pop без
--index выкладывает все правки на диск, и индекс под будущий коммит придётся
собирать заново.
Почему запись остаётся
apply проводит трёхстороннее слияние с базой в первом родителе W. Если ветка за это время
поменяла те же строки, будет конфликт, и потерять запись в этот момент значило бы потерять правки.
Запись остаётся и при отказе: своя правка в файле, который трогает stash, сорвёт слияние, и
pop правки не применит. С -u тут ловушка: неотслеживаемые файлы git
выкладывает после попытки слияния, даже сорвавшейся.
$ git stash pop # сводка status из вывода убрана
error: Your local changes to the following files would be overwritten by merge:
handler.go
Please commit your changes or stash them before you merge.
Aborting
The stash entry is kept in case you need it again.
$ git status --short
M handler.go
?? cache.go
$ git restore handler.go
$ git stash pop # сводка status из вывода убрана
cache.go already exists, no checkout
error: could not restore untracked files from stash
The stash entry is kept in case you need it again.
Второй pop применил правки handler.go и main.go, но из-за
уже лежащего cache.go вернул ошибку и запись оставил. Сверь
git stash show -p --include-untracked с диском и удали запись сам.
Как довести до конца
- Разреши конфликт и выполни
git restore --staged .: после конфликта чисто применённые правки лежат в индексе, хотя прятались недобавленными. - Проверь результат и удали запись:
git stash drop. - Не хочешь разбирать конфликт:
git stash branch имясоздаст ветку от коммита, где правки делались, применит их с индексом и удалит запись.
Новый файл остаётся на диске и едет на любую ветку. Если других правок нет,
git stash отвечает No local changes to save и выходит с кодом 0, так
что скрипт примет это за успех. Для новых файлов нужен git stash -u.
.gitignore действует только на неотслеживаемые файлы:
файл из индекса git видит, пока его не убрать через git rm --cached. Если новый файл
пропал, его задело правило шире задуманного. Какое, скажет git check-ignore -v путь:
файл правил, строка и шаблон.Файл виден, хотя он в игноре
Файл попал в репозиторий раньше правила. Для отслеживаемых путей check-ignore
молчит, а с --no-index показывает, какая строка сработала бы:
$ echo 'config.local.yaml' >> .gitignore
$ echo 'debug: true' >> config.local.yaml
$ git status --short
M .gitignore
M config.local.yaml
$ git check-ignore -v config.local.yaml; echo "rc=$?"
rc=1
$ git check-ignore -v --no-index config.local.yaml
.gitignore:2:config.local.yaml config.local.yaml
Лечится git rm --cached и коммитом, последствия для коллег разобраны в главе 1.3.
Новый файл не попадает в коммит
| Правило | Что задело | Как надо |
|---|---|---|
server | каталог cmd/server/ со всем кодом | /server |
logs/ и !logs/.gitkeep | отрицание внутри исключённого каталога не работает | logs/* и !logs/.gitkeep |
*.log в глобальном файле | у тебя файл скрыт, у коллег виден | правило проекта держать в .gitignore |
Источники правил по старшинству: командная строка, .gitignore,
.git/info/exclude, core.excludesFile. Два последних коллеги не видят, и
check-ignore -v сразу покажет, из какого файла пришло правило. Всё скрытое перечислит
git status --short --ignored.
Подсказка The following paths are ignored by one of your .gitignore files появляется,
только если назвать игнорируемый путь явно. git add . пропускает такие файлы молча.
После переноса каталогов смотри git status: строка D там, где ждёшь
R, значит, что новое место файла скрыто правилом.
core.autocrlf не решает проблему: это настройка машины, а не
репозитория. Решает .gitattributes с * text=auto eol=lf и коммит после
git add --renormalize ..Как убедиться
git diff --stat --ignore-cr-at-eol покажет настоящий размер правки, а
git ls-files --eol место CRLF: i/crlf значит, что он уже в репозитории,
i/lf w/crlf значит, что только на диске.
Почему не core.autocrlf
- Установщик Git for Windows ставит
true, но хватит одного участника с другим значением, и CRLF снова в истории. - С
trueна диске CRLF, иgofmt -l .перечисляет все Go-файлы. - После смены значения в готовом клоне файлы с CRLF всплывают изменёнными по одному, когда их трогают.
Как починить
$ echo '* text=auto eol=lf' > .gitattributes
$ git add .gitattributes
$ git add --renormalize .
$ git commit -qm "Хранить концы строк как LF"
# у каждого, на чистой рабочей копии:
$ git rm -r -q --cached .
$ git restore --staged --worktree .
eol=lf перекрывает core.autocrlf, и настройка на машинах больше ни на что
не влияет. Файлам, которым нужен CRLF, пишут своё правило, например
*.bat text eol=crlf. Коммит нормализации меняет каждую строку затронутых файлов, и
blame припишет их ему. Его хеш кладут в .git-blame-ignore-revs: GitHub читает файл
сам, а локальному git blame нужна настройка blame.ignoreRevsFile (глава 2.3).
git restore --staged --worktree . после rm --cached пишет каждый файл из
HEAD, и незакоммиченные правки пропадут, поэтому сначала коммит или git stash.
Одного git restore . мало: по данным stat файлы не менялись, и git их
не тронет.
2.3История
Вопрос к истории почти всегда один: кто, когда и зачем поменял вот это. log отбирает
коммиты по автору, дате, сообщению и пути, blame раскладывает файл по строкам,
-S и -G находят коммит по тексту правки, -L ведёт историю одной
функции. У каждого инструмента есть место, где он молча отвечает не то: коммит с gofmt, перенос кода
в другой файл, merge-коммит, метка в теле функции.
- Почему
git log --sinceвыводит коммит, написанный за два дня до указанной даты, и как отбирать коммиты по автору, сообщению и пути. - Как добраться в
blameдо настоящего автора строки сквозь коммит с gofmt и сквозь перенос функции в другой файл. - Чем
git log -Sотличается от-Gи какой из них найдёт смену значения. - Как git определяет границы Go-функции в
git log -Lи что ломается безdiff=golang.
Весь граф: log --all
Примеры главы идут на одном репозитории: Аня, Борис и Вера десять дней пишут Go-сервис
shop. Борис вёл ветку pg и влил её merge-коммитом, у Веры осталась невлитая
ветка export:
$ git log --oneline --graph --all
* 2c226d5 (HEAD -> main) Не учитывать коммит с gofmt в blame
* f9b303c Find: брать блокировку на чтение
| * c66a3df (export) Выгрузка каталога в CSV
|/
* 4639a26 Вынести writeJSON в httpx.go
* c474700 Find: сравнивать теги без учёта регистра
* ef10c23 Поднять пул соединений до 25
* f681ab6 (tag: v0.1.0) Merge branch 'pg'
|\
| * d56ecd1 (pg) Таймаут запроса к базе
| * 01abbd5 Хранилище в PostgreSQL
|/
* d0588b3 Прогнать gofmt -s
* 501ae3a writeJSON: логировать ошибку кодирования
* 6bd2e79 Отдавать товар по HTTP
* af4a540 Поиск товаров по тегам
* b71aed9 Расчёт скидки по промокоду
* c4fe965 Хранилище товаров в памяти
* 2770e8a Создать модуль shop
Без --all обход начинается от HEAD, и коммита c66a3df в выводе не будет: из
main он недостижим. С --all обход идёт от всех ссылок в refs/,
включая refs/stash: после git stash в графе появится «WIP on main».
Фильтры: автор, сообщение, дата, путь
--author сравнивает регулярное выражение со строкой Имя <почта> из
заголовка коммита, так что искать можно и по почте:
$ git log --oneline --author=Борис
ef10c23 Поднять пул соединений до 25
d56ecd1 (pg) Таймаут запроса к базе
01abbd5 Хранилище в PostgreSQL
501ae3a writeJSON: логировать ошибку кодирования
$ git log --oneline --author=example.org
b71aed9 Расчёт скидки по промокоду
Второй запрос нашёл коммит, который пропустил первый: скидки Борис коммитил с домашнего ноутбука, под
латинским именем и личной почтой. Как склеить две личности, показано в разделе про shortlog.
--grep ищет по всему сообщению, вместе с телом: номер задачи SHOP-42 записан
в теле. Одинаковые фильтры складываются через «или», разные через «и»: два
--author вернут коммиты обоих, а --grep с --author только то, что
подходит под оба. --all-match требует совпадения со всеми --grep, -i
не различает регистр, --invert-grep переворачивает условие.
$ git log --oneline --grep=SHOP
ef10c23 Поднять пул соединений до 25
d56ecd1 (pg) Таймаут запроса к базе
$ git log --oneline --grep=SHOP --author=Аня
$ git log --oneline --grep=Find --grep=блокиров --all-match
f9b303c Find: брать блокировку на чтение
С датами git ведёт себя неожиданно. В коммите их две: дата автора, когда правку написали, и дата
коммитера, когда записали сам объект коммита. git log печатает первую, а
--since и --until сравнивают вторую. Пока коммит не переписывали, даты
совпадают. Ветку pg перед слиянием перебазировали на main (глава 3.3), rebase
пересоздал её коммиты и поставил им новую дату коммитера. Столбцы задаёт --format:
%h хеш, %ad дата автора, %cd дата коммитера, %s заголовок.
$ git log --format='%h %ad %cd %s' --date=short --since='2026-09-05 00:00' --until='2026-09-07 23:59'
f681ab6 2026-09-07 2026-09-07 Merge branch 'pg'
d56ecd1 2026-09-03 2026-09-07 Таймаут запроса к базе
01abbd5 2026-09-03 2026-09-07 Хранилище в PostgreSQL
d0588b3 2026-09-07 2026-09-07 Прогнать gofmt -s
501ae3a 2026-09-05 2026-09-05 writeJSON: логировать ошибку кодирования
Два коммита, написанные 3 сентября, прошли фильтр «с 5 сентября»: седьмого числа их пересоздали.
--since=2026-09-07 означает седьмое сентября в тот час и минуту, когда запущена команда.
Запусти её в 16:26, и всё, что закоммичено седьмого утром, в вывод не попадёт. yesterday и
2.weeks.ago тоже отсчитываются от текущего момента. Время пиши явно:
--since='2026-09-07 00:00'. И ещё: --since прекращает обход на первом слишком
старом коммите, так что коммит со сбитыми часами прячет более новые за собой. --since-as-filter
(git 2.37) обходит историю целиком и только отбрасывает старое.
Путь после -- оставляет коммиты, которые его меняли: файл, каталог или шаблон в кавычках,
-- '*_test.go', чтобы его раскрыл git, а не оболочка. Без -- файл, которого нет
в рабочей копии, git отвергнет с fatal: ambiguous argument. С разделителем находится и он:
$ git log --oneline --all -- export.go
c66a3df (export) Выгрузка каталога в CSV
С путём git log упрощает историю. Коммиты, не менявшие путь, из вывода пропадают, а на
merge-коммите обход уходит только в того родителя, от которого файл достался слиянию без изменений:
$ git log --oneline -- store/pg.go
ef10c23 Поднять пул соединений до 25
d56ecd1 (pg) Таймаут запроса к базе
01abbd5 Хранилище в PostgreSQL
$ git log --oneline --first-parent -- store/pg.go
ef10c23 Поднять пул соединений до 25
f681ab6 (tag: v0.1.0) Merge branch 'pg'
В первом выводе нет f681ab6, хотя pg.go попал в main именно этим
слиянием: в merge-коммите файл такой же, как во втором родителе, и git пошёл туда. Второй вывод
отвечает на другой вопрос: как файл менялся в самой main, и ветка pg в нём
выглядит одним коммитом. И слияние, и ветку покажет --full-history. За переименованием путь
не пойдёт, для этого --follow из главы 1.1.
Что внутри коммита: --stat, -p и show
git log --stat добавляет к каждому коммиту список файлов с числом строк, -p полный
diff. git show выводит один коммит целиком: заголовок, сообщение с телом и diff, без аргумента
берёт HEAD.
$ git show ef10c23
commit ef10c2333987b8a55db8926f8dd27d7dc55bed20
Author: Борис <boris@example.com>
Date: Tue Sep 8 10:00:00 2026 +0300
Поднять пул соединений до 25
Под нагрузкой запросы ждали свободного соединения. SHOP-42
diff --git a/store/pg.go b/store/pg.go
index de8af62..0504a56 100644
--- a/store/pg.go
+++ b/store/pg.go
@@ -16,7 +16,7 @@ func NewPg(dsn string) (*PgStore, error) {
if err != nil {
return nil, err
}
- db.SetMaxOpenConns(10)
+ db.SetMaxOpenConns(25)
db.SetConnMaxIdleTime(time.Minute)
return &PgStore{db: db}, nil
}
После @@ стоит func NewPg(...): git сам нашёл над правкой строку, похожую на
начало функции. Как он её выбирает, пригодится в разделе про log -L.
Через двоеточие show достаёт файл из любой ревизии, не трогая рабочую копию:
git show v0.1.0:go.mod. Путь в такой записи считается от корня репозитория. Из подкаталога store запись
HEAD:pg.go упадёт с подсказкой, а HEAD:./pg.go сработает.
У merge-коммита git log -p diff не печатает вовсе, а git show печатает
комбинированный, у слияния без конфликтов пустой. Почему так, разобрано в последнем вопросе главы.
blame: чья это строка
git blame идёт по истории от HEAD назад и приписывает каждой строке файла коммит, в котором
она в последний раз появилась или изменилась. -L ограничивает строки:
$ git blame --date=short -L 47,54 store/store.go
af4a540b (Аня 2026-09-02 47) func (s *Store) Find(tags ...string) []Item {
f9b303c7 (Вера 2026-09-10 48) s.mu.RLock()
f9b303c7 (Вера 2026-09-10 49) defer s.mu.RUnlock()
af4a540b (Аня 2026-09-02 50) var out []Item
af4a540b (Аня 2026-09-02 51) next:
af4a540b (Аня 2026-09-02 52) for _, it := range s.items {
af4a540b (Аня 2026-09-02 53) for _, t := range tags {
c4747000 (Аня 2026-09-08 54) if !hasTag(it, t) {
Слева хеш коммита, дальше автор, дата, номер строки и сама строка. Хеш на знак длиннее обычного:
первую позицию blame оставляет под пометки, например крышку у строк из корневого коммита. В восьми
строках Find три коммита: Аня функцию написала и потом заменила проверку тега, Вера добавила
блокировку. Глубже последнего изменения blame не видит. Чтобы заглянуть дальше, его запускают на
родителе найденного коммита: git blame c474700^ -- store/store.go.
Хуже всего blame справляется с переносом кода. Вера вынесла writeJSON из handler.go
в новый httpx.go, и для blame весь файл написан ею:
$ git blame --date=short -L 12,15 httpx.go
4639a26d (Вера 2026-09-09 12) w.WriteHeader(status)
4639a26d (Вера 2026-09-09 13) if err := json.NewEncoder(w).Encode(v); err != nil {
4639a26d (Вера 2026-09-09 14) log.Printf("writeJSON: %v", err)
4639a26d (Вера 2026-09-09 15) }
$ git blame -C --date=short -L 12,15 httpx.go
6bd2e79f handler.go (Аня 2026-09-04 12) w.WriteHeader(status)
501ae3a9 handler.go (Борис 2026-09-05 13) if err := json.NewEncoder(w).Encode(v); err != nil {
501ae3a9 handler.go (Борис 2026-09-05 14) log.Printf("writeJSON: %v", err)
501ae3a9 handler.go (Борис 2026-09-05 15) }
С -C blame ищет добавленные строки в других файлах того же коммита и находит их в
handler.go: появился столбец с исходным файлом, а обработку ошибки, оказывается, дописал
Борис. Кусок засчитывается, если в нём не меньше 40 букв и цифр, поэтому короткие импорты остаются за
Верой. Двойной -C ищет ещё и в файлах, которые коммит не трогал (только для коммита,
создавшего файл), тройной ищет в любом коммите. -M ловит блоки, переставленные внутри одного
файла, с порогом 20 знаков.
Коммит «прогнал gofmt»
Борис писал price.go в редакторе без gofmt: отступы пробелами, скобки вокруг условий,
for i, _ := range. Когда CI начал проверять gofmt -l, Вера прогнала
gofmt -s -w и закоммитила результат. Логика не изменилась, а blame теперь приписывает
Вере каждую строку с отступом, 18 из 30 непустых:
$ git blame --date=short -L 17,22 price/price.go
b71aed9a (Boris 2026-09-02 17) func Discount(price float64, code string) (float64, error) {
d0588b30 (Вера 2026-09-07 18) if code == "" {
d0588b30 (Вера 2026-09-07 19) return price, nil
d0588b30 (Вера 2026-09-07 20) }
d0588b30 (Вера 2026-09-07 21) pct, ok := codes[strings.ToUpper(code)]
d0588b30 (Вера 2026-09-07 22) if !ok {
Можно попробовать -w, сравнение без учёта пробелов:
$ git blame -w --date=short -L 17,22 price/price.go
b71aed9a (Boris 2026-09-02 17) func Discount(price float64, code string) (float64, error) {
d0588b30 (Вера 2026-09-07 18) if code == "" {
b71aed9a (Boris 2026-09-02 19) return price, nil
b71aed9a (Boris 2026-09-02 20) }
b71aed9a (Boris 2026-09-02 21) pct, ok := codes[strings.ToUpper(code)]
d0588b30 (Вера 2026-09-07 22) if !ok {
Отступы -w простил, но две строки остались за Верой: gofmt убрал скобки из
if (code == "") и if (!ok), а это уже не пробелы. Ещё он сортирует импорты,
разбивает if t == tag { continue } на несколько строк, а с -s переписывает
for i, _ := range. --ignore-rev действует иначе: blame пропускает коммит
и каждой тронутой им строке ищет похожую в родителе.
$ git blame --ignore-rev d0588b3 --date=short -L 17,22 price/price.go
b71aed9a (Boris 2026-09-02 17) func Discount(price float64, code string) (float64, error) {
b71aed9a (Boris 2026-09-02 18) if code == "" {
b71aed9a (Boris 2026-09-02 19) return price, nil
b71aed9a (Boris 2026-09-02 20) }
b71aed9a (Boris 2026-09-02 21) pct, ok := codes[strings.ToUpper(code)]
b71aed9a (Boris 2026-09-02 22) if !ok {
Похожесть git считает по парам соседних символов без учёта регистра и пробелов. Пару он ищет сначала среди строк, которые коммит заменил в том же ханке, не нарушая порядка, потом во всём файле родителя, уже без порядка, но только при 10 общих парах и больше. Строки, которые коммит добавил, ищут пару так же: без пары остаются за ним, а длинная может уйти к похожей строке из другого места файла.
--ignore-rev строка ищет пару в родителе сначала в своём ханке по
порядку, потом во всём файле, если она достаточно длинная. Два коротких импорта пары не нашли.
Набирать --ignore-rev каждый раз неудобно, да и хеш знают не все. Список таких коммитов
держат в файле в корне репозитория, по полному хешу на строку, и подключают настройкой. Сразу включи и
пометки:
$ cat .git-blame-ignore-revs
# Прогнать gofmt -s
d0588b3099049d709d1774085d1601d5d23eea5e
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
$ git config blame.markUnblamableLines true
$ git config blame.markIgnoredLines true
$ git blame --date=short -L 3,7 price/price.go
b71aed9a (Boris 2026-09-02 3) import (
*d0588b3 (Вера 2026-09-07 4) "errors"
*d0588b3 (Вера 2026-09-07 5) "math"
?b71aed9 (Boris 2026-09-02 6) "strings"
b71aed9a (Boris 2026-09-02 7) )
Импорты у Бориса шли в порядке strings, math, errors, gofmt
расставил их по алфавиту. "strings" пару нашёл, и markIgnoredLines пометил
его ?: строка прошла сквозь игнорируемый коммит к более раннему. "errors" и
"math" стоят в ханке выше него, порядок не дал им пары, а для поиска по файлу они коротки:
у них 9 и 7 пар символов. Они остались за d0588b3, и markUnblamableLines
пометил их *. Без пометок не понять, где ответ точный.
Имя .git-blame-ignore-revs в git не зашито, но его понимают серверы: GitHub учитывает файл в
blame сам, GitLab с версии 17.11 после галочки «Ignore specific revisions». Настройку
blame.ignoreRevsFile каждый выставляет у себя, конфиг не версионируется. В тот же файл идёт
коммит с переименованием пакета: строки store.ErrNotFound → catalog.ErrNotFound
сопоставляются так же хорошо. Хеш нужен полный и тот, что лёг в main. После squash или rebase
при слиянии он другой, а неизвестный хеш git молча пропускает, поэтому его дописывают отдельным коммитом
после слияния, как Вера в 2c226d5.
log -S и -G: когда появилась или пропала строка
blame знает только строки, которые есть сейчас. Когда строки уже нет или нужен коммит, где поменялось
значение, ищут по тексту изменений. -S<строка> отбирает коммиты, в которых изменилось число вхождений строки в файле, то
есть она появилась или исчезла. -G<regex> отбирает коммиты, в diff которых есть
добавленная или удалённая строка, подходящая под регулярное выражение. Размер пула соединений в
pg.go меняли дважды:
$ git log --oneline -S SetMaxOpenConns
01abbd5 Хранилище в PostgreSQL
$ git log --oneline -G SetMaxOpenConns
ef10c23 Поднять пул соединений до 25
01abbd5 Хранилище в PostgreSQL
-S нашёл только коммит, где вызов появился. Когда Борис поднял пул до 25, строка с
SetMaxOpenConns в diff ушла и пришла снова, вхождение осталось одно, и для -S
ничего не произошло. -G смотрит на строки diff и видит обе правки. Через -S
смену значения ищут по самому значению:
$ git log --oneline -S 'SetMaxOpenConns(10)'
ef10c23 Поднять пул соединений до 25
01abbd5 Хранилище в PostgreSQL
$ git log --oneline -G 'SetMaxOpenConns(10)'
$ git log --oneline -G 'SetMaxOpenConns\(10\)'
ef10c23 Поднять пул соединений до 25
01abbd5 Хранилище в PostgreSQL
-G с той же строкой не нашёл ничего: для него это регулярное выражение, скобки группируют,
и искал он SetMaxOpenConns10. -S берёт строку буквально, пока не добавлен
--pickaxe-regex.
-S | -G | |
|---|---|---|
| Что проверяет | число вхождений в файле до и после коммита | добавленные и удалённые строки diff |
| Аргумент | строка; с --pickaxe-regex регулярное выражение | всегда регулярное выражение |
| Правка значения в той же строке | не видит, если искомый текст в строке сохранился | видит |
| Строку переставили внутри файла | не видит | видит, если diff показал её удалённой и добавленной |
| Merge-коммиты | пропускает, нужен -m | пропускает, нужен -m |
С -p оба флага выводят diff только файлов с совпадением, весь коммит вернёт
--pickaxe-all.
log -L: история одной функции
git log -L ведёт историю куска строк: на каждом коммите пересчитывает, куда кусок сдвинулся,
и показывает только правки внутри него. Кусок задают номерами (-L 47,61:store/store.go),
парой регулярных выражений (-L '/^func (s \*Store) Find/,/^}/:store/store.go') или именем
функции:
$ git log --oneline -L :Find:store/store.go
f9b303c Find: брать блокировку на чтение
diff --git a/store/store.go b/store/store.go
index 7942ad4..7ef9778 100644
--- a/store/store.go
+++ b/store/store.go
@@ -47,2 +47,4 @@ func (s *Store) Put(it Item) {
func (s *Store) Find(tags ...string) []Item {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
var out []Item
af4a540 Поиск товаров по тегам
diff --git a/store/store.go b/store/store.go
index 269ecc7..20380c3 100644
--- a/store/store.go
+++ b/store/store.go
@@ -43,0 +46,2 @@ func (s *Store) Put(it Item) {
+func (s *Store) Find(tags ...string) []Item {
+ var out []Item
Кусок обрывается на var out []Item, и коммит c474700, поменявший проверку в
цикле, в вывод не попал. :Find: тоже регулярное выражение: git берёт первую строку-заголовок
функции, где оно совпало, и тянет кусок до следующего заголовка. Заголовки ищутся как для
строки @@ (глава 2.2): без драйвера годится любая строка, которая начинается с буквы, а
gofmt ставит метку next: в первый столбец, и она обрезала функцию. Имя Put после
@@ не ориентир: git 2.54 ищет его над контекстом, раздутым до длины куска, и на куске
подлиннее там будет Get.
Драйвер golang включается атрибутом, а не расширением файла, и метку заголовком не считает:
$ echo '*.go diff=golang' >> .gitattributes
$ git log --oneline --no-patch -L :Find:store/store.go
f9b303c Find: брать блокировку на чтение
c474700 Find: сравнивать теги без учёта регистра
af4a540 Поиск товаров по тегам
Кусок дошёл до hasTag, и в выводе все три коммита. --no-patch оставляет от вывода только список коммитов.
-L :Find: идёт от совпавшего заголовка до
следующего. Без драйвера следующим заголовком оказывается метка next:, и правка в цикле
остаётся за границей. С diff=golang граница переезжает на func hasTag.
На тот же драйвер опираются git blame -L :Find и git grep -p. -L не терпит pathspec (fatal: -L<range>:<file> cannot be used
with pathspec) и стартует только от одной ревизии. Переименование файла он проходит, перенос функции в
другой файл нет, об этом вопрос 3.
git grep: поиск в любой ревизии
git grep ищет только в отслеживаемых файлах, поэтому не заходит в .git и в то,
что закрыто .gitignore. От grep -r его отличает другое: после шаблона
можно перечислить ревизии, и поиск пойдёт по их деревьям без переключения:
$ git grep -n SetMaxOpenConns
store/pg.go:19: db.SetMaxOpenConns(25)
$ git grep -n SetMaxOpenConns v0.1.0 pg
v0.1.0:store/pg.go:19: db.SetMaxOpenConns(10)
pg:store/pg.go:19: db.SetMaxOpenConns(10)
Префикс перед путём называет ревизию: так сверяют настройку в релизе и в ветке.
Передать можно и всю историю, $(git rev-list --all), но строка повторится для каждого
коммита, где она есть; момент изменения проще найти через log -G.
-p печатает перед совпадением заголовок функции, в которой оно нашлось, по тем же правилам,
что и -L. С атрибутом из прошлого раздела:
$ git grep -p -n 'continue next'
store/store.go=47=func (s *Store) Find(tags ...string) []Item {
store/store.go:55: continue next
Без атрибута на месте func (s *Store) Find здесь стояла бы метка next:.
shortlog: сводка по авторам
git shortlog группирует коммиты по автору. -s оставляет только число коммитов,
-n сортирует по нему, -e добавляет почту:
$ git shortlog -sn
6 Аня
4 Борис
4 Вера
1 Boris
Борис посчитан дважды, один раз под латинским именем. Такие личности склеивает файл
.mailmap в корне репозитория: слева настоящие имя и почта, справа почта, с которой записан
коммит.
$ cat .mailmap
Борис <boris@example.com> <boris.dev@example.org>
$ git shortlog -sne
6 Аня <anya@example.com>
5 Борис <boris@example.com>
4 Вера <vera@example.com>
$ git log --oneline --author=Борис
ef10c23 Поднять пул соединений до 25
d56ecd1 (pg) Таймаут запроса к базе
01abbd5 Хранилище в PostgreSQL
501ae3a writeJSON: логировать ошибку кодирования
b71aed9 Расчёт скидки по промокоду
.mailmap читают shortlog, blame и log (там его включает
log.mailmap, по умолчанию true), и --author=Борис теперь находит коммит со
скидками. Сами коммиты не меняются.
Без -s shortlog перечисляет заголовки, и с диапазоном из главы 1.2 получается черновик
заметок к релизу:
$ git shortlog --no-merges v0.1.0..HEAD
Аня (1):
Find: сравнивать теги без учёта регистра
Борис (1):
Поднять пул соединений до 25
Вера (3):
Вынести writeJSON в httpx.go
Find: брать блокировку на чтение
Не учитывать коммит с gofmt в blame
Для парной работы есть --group=trailer:co-authored-by (git 2.29): он считает соавторов из
трейлера Co-authored-by:, а вместе с --group=author и авторов, и соавторов сразу.
Если ревизия не указана, а стандартный ввод не терминал, git shortlog читает лог со
стандартного ввода, как в git log | git shortlog. В CI вывод пустой, а если ввод открыт и
молчит, команда висит. Ревизию указывают всегда:
$ echo | git shortlog -sn
$ echo | git shortlog -sn HEAD
6 Аня
4 Борис
4 Вера
1 Boris
Вопросы
4git blame --ignore-rev <хеш>, а
-w прощает только пробелы. Для команды полный хеш кладут в .git-blame-ignore-revs
в корне и выставляют blame.ignoreRevsFile; GitHub читает файл сам, GitLab по галочке. Хеш дописывают
после слияния, а репозитории без файла не ломаются, если путь записан с :(optional)
(git 2.52).Почему blame упирается в форматирование
blame приписывает строку последнему коммиту, который поменял её текст, а gofmt меняет текст почти везде:
отступы, выравнивание, скобки вокруг условий, порядок импортов. -w прощает только пробелы, и
строки, где gofmt убрал скобки или , _ в range, остаются за ним.
--ignore-rev ищет каждой тронутой коммитом строке похожую в родителе, сначала в ханке по
порядку, потом во всём файле. Короткие переставленные импорты могут остаться без пары, а добавленная коммитом
строка может уйти к чужой похожей. Поэтому
сразу включают blame.markUnblamableLines и blame.markIgnoredLines: строки без
пары получат *, отданные другому коммиту ?.
Для всей команды
В корень кладут .git-blame-ignore-revs с полными хешами и комментариями, каждый выполняет
git config blame.ignoreRevsFile .git-blame-ignore-revs. GitHub учитывает файл сам, GitLab с
17.11 по галочке в настройках blame. Но если файла нет, настройка ломает blame: в чужом репозитории при
глобальном конфиге или на старой ревизии. Префикс :(optional) из git 2.52 это чинит:
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
$ git switch -q --detach v0.1.0
$ git blame --date=short -L 1,1 main.go
fatal: could not open object name list: .git-blame-ignore-revs
$ git config blame.ignoreRevsFile ':(optional).git-blame-ignore-revs'
$ git blame --date=short -L 1,1 main.go
^2770e8a (Аня 2026-09-01 1) package main
Короткий хеш в файле даст fatal: invalid object name, а полный хеш коммита, которого
нет в репозитории, git с версии 2.30 молча пропускает. После squash-merge или rebase коммит с
форматированием получает в main новый хеш, и строка из ветки перестаёт действовать без
предупреждений. Хеш берут из main и дописывают отдельным коммитом.
-S находит коммиты, где изменилось число вхождений строки
в файле, а -G те, в diff которых есть добавленная или удалённая строка под
регулярное выражение. Правка значения в той же строке для -S SetMaxOpenConns не изменение:
её видит -G или -S по самому значению. Оба пропускают merge-коммиты без
-m.Два способа сравнивать
-S считает вхождения строки в каждом файле до коммита и после; числа разные, коммит
найден. -G строит diff и прогоняет выражение по строкам с + и -.
Разница видна даже на подстроках: Аня заменила slices.Contains на свою hasTag,
которая вызывает slices.ContainsFunc:
$ git log --oneline -S slices.Contains
af4a540 Поиск товаров по тегам
$ git log --oneline -G slices.Contains
c474700 Find: сравнивать теги без учёта регистра
af4a540 Поиск товаров по тегам
Подстрока slices.Contains есть в обоих вызовах, число вхождений не изменилось, и
-S коммит c474700 пропустил.
Как искать значение
git log -S 'SetMaxOpenConns(10)'вернёт коммит, где значение 10 появилось, и коммит, где оно пропало. Строка берётся буквально.git log -G 'SetMaxOpenConns\('вернёт каждый коммит, который трогал строку с вызовом. Без\git упадёт сfatal: invalid regex: parentheses not balanced(это macOS, на Linux текст другой), а парные скобки без экранирования молча ничего не найдут.
Слияния
Без -m оба флага merge-коммиты не проверяют, с -m слияние сравнивается с
каждым родителем:
$ git log --oneline -m -S SetMaxOpenConns
f681ab6 (from d0588b3) (tag: v0.1.0) Merge branch 'pg'
01abbd5 Хранилище в PostgreSQL
(from d0588b3): относительно первого родителя вызов появился, слияние принесло его в
main. Так отвечают на вопрос «когда настройка попала в основную ветку», а
01abbd5 говорит, когда её написали.
git log -G 'SetMaxOpenConns(10)' завершается без ошибки и без вывода: скобки группируют,
и выражение ищет SetMaxOpenConns10. Пустой ответ легко принять за «правки не было». То
же с -S … --pickaxe-regex, выражение там тоже расширенное.
git log -L :имя:файл показывает правки внутри функции, но на
коммите переноса останавливается. git blame -C по новому файлу называет исходный файл и
коммиты, а git log -L :имя:старый_файл <перенос>^ продолжает историю на старом месте.
Для Go в .gitattributes нужен *.go diff=golang, иначе границы функции
окажутся не там.Шаг 1: история на новом месте
-L следит за куском внутри одного файла и проходит переименование, но функция из другого
файла для него появилась с нуля:
$ git log --oneline --no-patch -L :writeJSON:httpx.go
4639a26 Вынести writeJSON в httpx.go
Шаг 2: откуда пришли строки
blame -C ищет добавленные строки в других файлах того же коммита, -s убирает
автора и дату. Если источник не нашёлся, пробуют -C -C и -C -C -C.
$ git blame -C -s -L 10,14 httpx.go
6bd2e79f handler.go 10) func writeJSON(w http.ResponseWriter, status int, v any) {
6bd2e79f handler.go 11) w.Header().Set("Content-Type", "application/json")
6bd2e79f handler.go 12) w.WriteHeader(status)
501ae3a9 handler.go 13) if err := json.NewEncoder(w).Encode(v); err != nil {
501ae3a9 handler.go 14) log.Printf("writeJSON: %v", err)
Шаг 3: история до переноса
Старый файл и коммит переноса 4639a26 известны, и -L от родителя этого
коммита продолжает историю на старом месте:
$ git log --oneline --no-patch -L :writeJSON:handler.go 4639a26^
501ae3a writeJSON: логировать ошибку кодирования
6bd2e79 Отдавать товар по HTTP
git log -p diff для слияний не печатает, git show
печатает комбинированный diff, у слияния без конфликтов пустой. Что слияние принесло в основную ветку, показывает сравнение с
первым родителем: --first-parent, --diff-merges=first-parent или
git diff M^1 M.Что показывают log и show
$ git log -p --oneline -1 f681ab6
f681ab6 (tag: v0.1.0) Merge branch 'pg'
$ git show --oneline f681ab6
f681ab6 (tag: v0.1.0) Merge branch 'pg'
$ git show --oneline --stat f681ab6
f681ab6 (tag: v0.1.0) Merge branch 'pg'
store/pg.go | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
У git log для слияний по умолчанию --diff-merges=off, у git show
dense-combined: результат сравнивается со всеми родителями сразу, и участки, совпавшие хоть
с одним, выбрасываются. pg.go совпадает с версией во втором родителе, патч пуст. А
статистику комбинированный diff всегда считает от первого родителя, отсюда 34 строки при пустом патче.
Сравнение с первым родителем
Первый родитель у слияния тот, куда вливали (глава 1.2). Разница с ним и есть то, что ветка принесла:
$ git log --oneline --first-parent -p -1 f681ab6 | head -9
f681ab6 (tag: v0.1.0) Merge branch 'pg'
diff --git a/store/pg.go b/store/pg.go
new file mode 100644
index 0000000..de8af62
--- /dev/null
+++ b/store/pg.go
@@ -0,0 +1,34 @@
+package store
+
С --first-parent такой формат включается сам, без него пишут
--diff-merges=first-parent или --dd (git 2.43). -m выдаёт diff с
каждым родителем отдельно.
Где это ещё всплывает
git log -- путьпрячет слияние, которое принесло файл, если файл совпал с одним из родителей. Слияние покажут--first-parentи--full-history.- Разрешённый конфликт в комбинированном diff виден: строки отличаются от обоих родителей. Сравнить
разрешение с тем, что git слил бы сам, умеет
--remerge-diff(git 2.36), об этом глава 3.2.