voidlearn
 

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 общих парах и больше. Строки, которые коммит добавил, ищут пару так же: без пары остаются за ним, а длинная может уйти к похожей строке из другого места файла.

b71aed9, Boris: до gofmt родитель d0588b3, Вера: gofmt -s выделено то, что без флагов blame отдаёт Вере blame --ignore-rev d0588b3 кому досталась строка, с пометками import ( "strings" "math" "errors" ) func Discount(…) { if (code == "") { return price, nil import ( "errors" "math" "strings" ) func Discount(…) { if code == "" { return price, nil …… b71aed9 Boris d0588b3 Вера * d0588b3 Вера * b71aed9 Boris ? b71aed9 Boris b71aed9 Boris b71aed9 Boris ? b71aed9 Boris ? строка не менялась: её и без флагов получает b71aed9 строку поменял gofmt, --ignore-rev нашёл ей пару: b71aed9, пометка ? пары нет: в ханке мешает порядок, для поиска по файлу строка коротка; пометка *
Сквозь коммит с форматированием. Без флагов blame останавливается на каждой строке, которую тронул gofmt. С --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 оставляет от вывода только список коммитов.

store/store.go в HEAD, строки 46–63 кто менял Find 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 // Find возвращает товары, у которых есть все теги. func (s *Store) Find(tags ...string) []Item { s.mu.RLock() defer s.mu.RUnlock() var out []Item next: for _, it := range s.items { for _, t := range tags { if !hasTag(it, t) { continue next } } out = append(out, it) } return out } func hasTag(it Item, tag string) bool { af4a540 Аня: написала Find f9b303c Вера: RLock в начале функции c474700 Аня: hasTag вместо slices.Contains заголовок функции для любого драйвера заголовок только без драйвера: строка начинается с буквы без атрибута: :Find: даёт строки 47–50, в выводе только f9b303c и af4a540 *.go diff=golang: строки 47–62, в выводе все три коммита
Где кончается функция. Кусок для -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 и авторов, и соавторов сразу.

В CI shortlog печатает пустоту или висит

Если ревизия не указана, а стандартный ввод не терминал, git shortlog читает лог со стандартного ввода, как в git log | git shortlog. В CI вывод пустой, а если ввод открыт и молчит, команда висит. Ревизию указывают всегда:

$ echo | git shortlog -sn
$ echo | git shortlog -sn HEAD
     6	Аня
     4	Борис
     4	Вера
     1	Boris

Вопросы

4
Суть: разово помогает git 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
Хеш из ветки после squash уже не тот

Короткий хеш в файле даст 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
Суть: у merge-коммита два родителя, и что считать его изменением, зависит от того, с каким сравнивать. 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, а также границу указанной даты. Если даты разных авторов расходятся, обе могут быть верными для разных вопросов об истории.