2.3История
Будешь искать не только автора строки, но и коммит, где смысл или текст впервые изменился. Сравнишь log, blame, -S, -G и -L и учтёшь, где переименование и массовое форматирование мешают истории.
- Почему
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 сам нашёл над правкой строку, похожую на
начало функции.
Через двоеточие 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.
Что стоит запомнить
- git blame связывает строки файла с коммитами по текущей истории.
- -S ищет изменение числа вхождений строки, а -G - строки, совпадающие с регулярным выражением в diff.
- -L ограничивает историю диапазоном строк или функции.
Частые ошибки
- gofmt может перетянуть blame на форматирующий commit; blame-ignore-revs помогает пройти его.
- Обычный blame может не проследить перенос функции в другой файл без опций копирования/переименования.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Сравни -S и -G
В старом коммите строка была timeout := 5, в новом стала timeout := 10. Какой поиск найдёт именно смену числа, а какой - изменённый текст?
git log -S'timeout := 5' -- file.go
git log -G'timeout := [0-9]+' -- file.go
Ответ и разбор
-S находит commit, где поменялось число вхождений точной строки; исчезновение старой строки обычно его найдёт. -G ищет diff-строки, совпадающие с регулярным выражением, поэтому сопоставит изменение обеих версий по паттерну.
Код-ревью
2. Пройди blame сквозь gofmt commit
Каждая строка теперь приписана коммиту «прогнал gofmt», хотя разработчик менял только форматирование. Как получить более полезную атрибуцию и что проверить?
git blame internal/client.go
Ответ и разбор
Добавь hash чисто форматирующего коммита в файл ревизий и выполни git blame --ignore-revs-file .git-blame-ignore-revs internal/client.go. Проверь, что этот commit действительно менял только форматирование: иначе можно скрыть реальную смысловую правку. Поделись настройкой с командой.
Применение
3. Найди изменение одной функции
Нужно проследить Go-функцию retryRequest в файле client.go и понять, в каких коммитах её меняли. Как ограничить log и что добавить для распознавания функций?
Ответ и разбор
Используй git log -L :retryRequest:client.go. Чтобы Git разбирал Go-границы функции, включи diff driver для Go через .gitattributes (например, *.go diff=golang) и настрой соответствующий driver там, где это требуется. Проверь результат на конкретных переименованиях.
Применение
4. Проверь дату и автора коммита
git log --since="2026-10-01" вывел commit, который в логе проекта выглядит датированным 29 сентября. Какие два времени коммита существуют и что проверить в фильтре?
Ответ и разбор
У commit есть author date и committer date; git log --since фильтрует по дате коммитера. Проверь полный timestamp и timezone через git show --format=fuller, а также границу указанной даты. Если даты разных авторов расходятся, обе могут быть верными для разных вопросов об истории.