voidlearn
 

1.3Три дерева

Сравнишь последний commit, индекс и рабочую копию как три состояния проекта. Потренируешься предсказывать результат add, commit, diff, restore и switch по направлению копирования.

Что сможешь объяснить после этой главы
  • Что лежит в .git/index и почему там перечислены все файлы проекта, а не только изменённые.
  • Почему после git add и ещё одной правки в коммит уходит старая версия файла.
  • Какие два снимка сравнивают git diff, git diff --staged и git diff HEAD.
  • Когда git switch переносит незакоммиченные правки на другую ветку, когда отказывается и когда молча затирает файл.
  • Как перестать отслеживать файл через git rm --cached и что после этого случится у коллег.

Три снимка одного проекта

Слово «дерево» в названии главы стоит условно. Речь о трёх полных наборах файлов, и только один из них устроен как объект tree из главы 1.1.

Возьмём маленький Go-проект из трёх файлов и сделаем первый коммит. Теперь каждый файл существует трижды. Коммит 7a1b9c3 хранит его в своём дереве, на этот коммит указывает HEAD (как HEAD находит коммит, разобрано в главе 1.2). В файле .git/index записаны те же пути с хешами блобов: это индекс, черновик следующего коммита. И сами файлы лежат в каталоге проекта, в рабочей копии, где их открывает редактор.

$ git add .
$ git commit -m "Первый коммит"
[main (root-commit) 7a1b9c3] Первый коммит
 3 files changed, 13 insertions(+)
 create mode 100644 README.md
 create mode 100644 go.mod
 create mode 100644 main.go
$ git ls-tree -r HEAD            # дерево коммита
100644 blob 8954bb97349bfe2a7799e6a7a64c6f747c635d6c    README.md
100644 blob 39c5ef80ac37708bfc5a6de808d737b8d9091e19    go.mod
100644 blob 54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6    main.go
$ git ls-files --stage           # индекс
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0       README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0       go.mod
100644 54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6 0       main.go
$ git status
On branch main
nothing to commit, working tree clean

Хеши в двух списках совпадают до символа, а working tree clean в ответе status означает ровно это: все три снимка одинаковы. Стоит поправить файл, и один снимок разойдётся с остальными. Команды git переносят содержимое из снимка в снимок.

Где лежитЧто внутриКак посмотреть
HEADобъекты в .git/objectstree и blob последнего коммита, неизменяемыеgit ls-tree -r HEAD, git show HEAD:main.go
Индексодин файл .git/indexплоский список: путь, режим, хеш блоба, данные statgit ls-files --stage, git show :main.go
Рабочая копиякаталог проектаобычные файлы; git читает их, только когда его попросятредактор, ls, cat

Запись :main.go (двоеточие и путь без имени коммита) указывает на версию файла в индексе.

Рабочая копия обычные файлы на диске Индекс .git/index: путь, режим, хеш, stat HEAD дерево коммита 7a1b9c3 в ветке main README.md как в индексе go.mod как в индексе main.go v3: правка после add README.md 8954bb9 go.mod 39c5ef8 main.go v2 527077f README.md 8954bb9 go.mod 39c5ef8 main.go v1 54c3e58 git add файл → blob в базе и запись в индексе git commit индекс → tree → commit, ветка сдвигается git switch feature дерево feature → индекс, HEAD → feature индекс → диск, только различающиеся файлы git diff что ещё не добавлено git diff --staged что уйдёт в коммит git diff HEAD что уйдёт при commit -a
Три снимка и команды между ними. Состояние после add и ещё одной правки: у main.go в каждом снимке своя версия. add и commit переносят содержимое слева направо, switch переписывает индекс и диск из коммита, а три варианта diff сравнивают снимки попарно.

Индекс изнутри

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

$ head -c 12 .git/index | xxd
00000000: 4449 5243 0000 0002 0000 0003            DIRC........

Версия 2, три записи. Сортирует их git по пути побайтно, поэтому README.md с заглавной буквы стоит раньше go.mod. В каждой записи лежат хеш блоба, режим (100644 у обычного файла, 100755 у исполняемого), флаги с номером стадии и ещё пачка полей, которые git взял у системного вызова lstat: время изменения, inode, владелец, размер. Отладочный режим ls-files их показывает:

$ git ls-files --debug main.go
main.go
  ctime: 1789562384:990065496
  mtime: 1789562384:990065496
  dev: 16777232 ino: 139124911
  uid: 501      gid: 20
  size: 93      flags: 0

Числа у тебя будут другие. Зачем они вообще нужны? Чтобы не читать файлы. По команде git status git не хеширует весь проект заново: он зовёт lstat, сверяет время, размер и inode с записью в индексе и при совпадении считает файл нетронутым, в содержимое не заглядывая. Проверим на файле, у которого поменялось только время:

$ touch README.md
$ git diff-files          # низкоуровневое сравнение индекса с диском
:100644 100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0000000000000000000000000000000000000000 M      README.md
$ git status --short
$ git diff-files

diff-files увидел новое время и доложил: файл, возможно, изменён, а хеш версии на диске неизвестен, отсюда нули. git status перечитал файл, получил прежний блоб и записал в индекс свежие данные stat, поэтому второй diff-files молчит. Бывает и наоборот: файл переписали сразу после записи индекса, а время не сдвинулось. В документации это называется racy git, и такие записи git сверяет по содержимому.

В обычном индексе каталогов нет, только файлы с полными путями вроде cmd/api/main.go. Объекты tree появляются при коммите, когда плоский список сворачивается в дерево, а готовые хеши неизменённых каталогов git держит в расширении TREE после записей, чтобы не пересчитывать их. Поэтому пустой каталог не добавить: git add tmp молча ничего не сделает.

Третья колонка ls-files --stage, тот самый ноль, это номер стадии. Во время конфликта у пути появляются три записи со стадиями 1, 2 и 3: общий предок и две стороны (глава 3.2). У индекса есть и старое имя. В первом коммите git, 7 апреля 2005 года, он назывался «current directory cache» и лежал в .dircache/index. Отсюда сигнатура DIRC и флаг --cached у diff и rm: видишь cached, значит речь об индексе.

add: из рабочей копии в индекс

Поменяем приветствие в main.go с "hello" на "hello, world". Пока правка живёт только на диске, и короткий статус ставит M во вторую колонку. Колонок две: первая сравнивает индекс с HEAD, вторая рабочую копию с индексом (формат подробно разобран в главе 2.1).

$ git status --short
 M main.go
$ find .git/objects -type f | wc -l     # три блоба, tree и commit
       5
$ git add main.go
$ find .git/objects -type f | wc -l
       6
$ git ls-files --stage main.go
100644 527077fd1407ddb47597f2cb96bb242e70d3b108 0       main.go
$ git hash-object main.go
527077fd1407ddb47597f2cb96bb242e70d3b108

add сделал две вещи: записал содержимое файла блобом в .git/objects (объектов стало шесть) и переписал запись main.go в индексе. Коммита ещё нет, а объект в базе уже есть. Добавишь файл ещё раз в другом виде, и первый блоб останется лежать без ссылок; как такие блобы находят, рассказано в главе 5.2.

Теперь допишем в main.go константу version, уже без add:

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   main.go

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   main.go

$ git status --short
MM main.go
$ git rev-parse HEAD:main.go :main.go
54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6
527077fd1407ddb47597f2cb96bb242e70d3b108

Один файл сразу в двух разделах, и противоречия тут нет. status сравнивает дважды, HEAD с индексом и индекс с диском, а main.go отличается в обоих случаях. В трёх снимках лежат три разные версии: 54c3e58 в коммите, 527077f в индексе и третья на диске, для которой блоба пока нет. Именно это состояние нарисовано на схеме.

Три diff: три пары снимков

Снимков три, пар для сравнения тоже три, и на каждую пару своя команда. В состоянии MM все три показывают разное. git diff без аргументов сравнивает индекс с рабочей копией, git diff --staged сравнивает HEAD с индексом:

$ git diff
diff --git a/main.go b/main.go
index 527077f..2639041 100644
--- a/main.go
+++ b/main.go
@@ -4,6 +4,8 @@ import "fmt"

 const greeting = "hello, world"

+const version = "1.0"
+
 func main() {
        fmt.Println(greeting)
 }
$ git diff --staged
diff --git a/main.go b/main.go
index 54c3e58..527077f 100644
--- a/main.go
+++ b/main.go
@@ -2,7 +2,7 @@ package main

 import "fmt"

-const greeting = "hello"
+const greeting = "hello, world"

 func main() {
        fmt.Println(greeting)

А git diff HEAD перепрыгивает через индекс, сравнивает коммит прямо с диском и видит обе правки разом, строка index у него 54c3e58..2639041:

$ git diff HEAD --stat
 main.go | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)
КомандаЧто с чемЧто показывает
git diffиндекс → рабочая копияправки, которые ещё не добавлены
git diff --staged
(то же, что --cached)
HEAD → индексчто уйдёт в коммит при git commit
git diff HEADHEAD → рабочая копиячто уйдёт в коммит при git commit -a

Посмотри на строку index в каждом заголовке: там короткие хеши двух сравниваемых версий. 527077f и 54c3e58 лежат в базе, а 2639041 git посчитал на лету по файлу с диска. Объекта с таким именем нет, git cat-file -t 2639041 ответит fatal: Not a valid object name 2639041. Блоб появится после add.

Новый файл, который ни разу не добавляли, не покажет ни одна из трёх команд: ни в индексе, ни в HEAD его нет, сравнивать не с чем. Создадим handler.go и спросим git diff HEAD --stat: в ответе будет только main.go. Чтобы увидеть новый файл в diff, не добавляя его содержимое, есть git add -N (intent to add, «намерение добавить»). В индекс попадает запись с хешем пустого блоба, и файлу уже есть с чем сравниваться:

$ git add -N handler.go
$ git ls-files --stage handler.go
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0       handler.go
$ git status --short
 A handler.go
MM main.go
$ git diff -- handler.go
diff --git a/handler.go b/handler.go
new file mode 100644
index 0000000..f40b8ea
--- /dev/null
+++ b/handler.go
@@ -0,0 +1,3 @@
+package main
+
+func handle() {}

A встала во вторую колонку: по мнению git, сам файл в индекс ещё не добавлен, там лежит пустышка. Как добавлять файл по частям через add -p, разобрано в главе 2.1. Дальше handler.go нам не нужен: запись-намерение снимет git rm --cached handler.go (о ней ниже), а сам файл удалим с диска обычным rm.

commit: из индекса в историю

Из рабочей копии git commit в коммит ничего не берёт. Он берёт индекс, сворачивает его в объекты tree, создаёт commit с этим деревом и родителем из HEAD и передвигает ветку, на которую указывает HEAD. В нашем состоянии MM в коммит уйдёт версия 527077f, а константа version, дописанная после add, останется на диске:

$ git write-tree                        # дерево, которое получится из индекса
5fa97ccc233adb75fc4b93178e107ef2677c9012
$ git commit -m "Приветствие целиком"
[main e198e94] Приветствие целиком
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git rev-parse "HEAD^{tree}"
5fa97ccc233adb75fc4b93178e107ef2677c9012
$ git status --short
 M main.go

Дерево, которое write-tree посчитал из индекса до коммита, совпало с деревом нового коммита. Можно пойти дальше: в копии репозитория, снятой до коммита, собрать тот же коммит тремя низкоуровневыми командами. Автор и даты те же, так что хеш сходится до символа:

$ tree=$(git write-tree)
$ commit=$(git commit-tree $tree -p HEAD -m "Приветствие целиком")
$ echo $commit
e198e94e516acb59df47bb33003909d31e74da6d
$ git update-ref refs/heads/main $commit
$ git log --oneline
e198e94 Приветствие целиком
7a1b9c3 Первый коммит

Настоящий git commit вдобавок запускает хуки, без -m открывает редактор и печатает сводку, но данные переносит именно так. С флагом -a он сначала делает add для всех отслеживаемых файлов. Создадим handler.go заново: в индексе его нет, и commit -a его не заметит.

$ git status --short
 M main.go
?? handler.go
$ git commit -a -m "Версия"
[main c65842e] Версия
 1 file changed, 2 insertions(+)
$ git status --short
?? handler.go

switch: из коммита в индекс и рабочую копию

git switch гонит данные в обратную сторону. Для примера возьмём репозиторий попроще: в main лежит только первый коммит 7a1b9c3, а в ветке feature поверх него есть коммит, где main.go стал HTTP-сервером и появился handler.go. Поправим README.md и переключимся:

$ git log --oneline --all
059dea8 HTTP-сервер
7a1b9c3 Первый коммит
$ git diff --stat main feature
 handler.go | 7 +++++++
 main.go    | 7 +++----
 2 files changed, 10 insertions(+), 4 deletions(-)
$ ls -i go.mod main.go
139133129 go.mod
139133620 main.go
$ echo '## Запуск' >> README.md
$ git switch feature
M       README.md
Switched to branch 'feature'
$ git ls-files --stage
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0       README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0       go.mod
100644 9de80c73578699b9ddf6c25a48ef89291f9c8cb2 0       handler.go
100644 327bda13f3114bab4a920f7a577123bbd421bc25 0       main.go
$ ls -i go.mod main.go
139133129 go.mod
139139089 main.go
$ git status --short
 M README.md

HEAD теперь указывает на feature. В индексе сменились две записи: main.go и появившийся handler.go. go.mod в обоих коммитах одинаков, и git его не трогал (inode прежний), а main.go записан заново. Правка в README.md пережила переключение, о чём говорит строка M README.md: файл в ветках одинаков, и его оставили как был.

HEAD было стало Ветка main коммит 7a1b9c3 Индекс и рабочая копия после git switch feature Ветка feature коммит 059dea8 README.md 8954bb9 go.mod 39c5ef8 main.go 54c3e58 handler.go нет в коммите README.md твоя правка осталась go.mod файл не тронут main.go переписан из feature handler.go создан из feature README.md 8954bb9 go.mod 39c5ef8 main.go 327bda1 handler.go 9de80c7 Если правка была в main.go, который в ветках разный: Your local changes … would be overwritten switch отказывает и ничего не трогает Если на диске свой handler.go, которого нет в индексе: неотслеживаемый: switch откажет игнорируемый: switch перезапишет его молча
Что switch делает с индексом и рабочей копией. HEAD переезжает на feature. Файлы, разные в двух коммитах, переписываются из целевой ветки, одинаковые остаются на месте вместе с твоими правками. Если правка лежит в файле, который пришлось бы переписать, переключения не будет.

Теперь вернёмся в main (правка в README.md переедет обратно тем же способом) и поменяем ещё и main.go. Этот файл в ветках разный, и деть правку при переключении некуда: версия из feature затёрла бы её. На это git не идёт и отказывает, ничего не тронув:

$ git status --short
 M README.md
 M main.go
$ git switch feature
error: Your local changes to the following files would be overwritten by checkout:
        main.go
Please commit your changes or stash them before you switch branches.
Aborting

Выхода четыре: закоммитить, спрятать правку в stash (глава 2.2), переключиться с --merge или выбросить правки через --discard-changes; все четыре разобраны в ответе на третий вопрос. Неотслеживаемые файлы защищены тем же правилом: лежит на диске свой handler.go, а в feature файл с таким путём закоммичен, и switch остановится с The following untracked working tree files would be overwritten.

Игнорируемый файл switch затирает без вопросов

На файлы из .gitignore эта защита не действует: git считает их расходным материалом. Пусть handler.go с черновиком у тебя игнорируется, а в feature файл с тем же путём закоммичен. После git switch feature на диске окажется версия из ветки, без ошибки и без предупреждения. Так легко лишиться локального конфига, который в текущей ветке игнорируется, а в старой ещё лежит в репозитории. С флагом --no-overwrite-ignore switch вместо перезаписи откажет. В справке switch этого флага нет, он описан у git checkout, но switch его понимает.

Как switch решает, что можно трогать

switch и git read-tree -m -u HEAD feature отдают решение одной и той же функции twoway_merge, слиянию двух деревьев. В документации read-tree ему посвящена таблица из 22 случаев; выжимка из неё есть в ответе на третий вопрос. Переключение можно повторить руками: read-tree, затем git symbolic-ref HEAD refs/heads/feature. Результат тот же, а на правке в main.go вместо отказа switch получишь error: Entry 'main.go' not uptodate. Cannot merge.

rm --cached: убрать из индекса, оставить на диске

git rm удаляет файл из индекса и с диска, git rm --cached только из индекса: файл остаётся в каталоге, но в следующий коммит не попадёт. Типичный случай: в репозиторий по ошибке попал .env. Одной строки в .gitignore мало: игнор действует только на файлы, которых нет в индексе (подробнее в главе 2.2):

$ echo ".env" > .gitignore
$ echo "DB_PASSWORD=changed" > .env
$ git status --short             # .env уже в .gitignore, а правку всё равно видно
 M .env
?? .gitignore
$ git rm --cached .env
rm '.env'
$ git status --short
D  .env
?? .gitignore
$ ls -A
.env
.git
.gitignore
go.mod
$ git add .gitignore
$ git commit -m "Не хранить .env в репозитории"
[main 0a99ec9] Не хранить .env в репозитории
 2 files changed, 1 insertion(+), 1 deletion(-)
 delete mode 100644 .env
 create mode 100644 .gitignore

D в первой колонке значит, что удаление уже в индексе. Строки ?? .env нет: из индекса файл ушёл, и .gitignore наконец на него действует. На диске файл цел. Для каталога понадобится -r, без него git rm --cached .idea ответит fatal: not removing '.idea' recursively without -r. Потерять что-то rm --cached не даст: если версия в индексе отличается и от HEAD, и от диска, он откажет с staged content different from both the file and the HEAD.

А что получит коллега? Боря склонировал репозиторий до этого коммита, и .env у него на диске нетронутый:

$ git pull                       # строки fetch опущены
Updating 5c3e0d5..0a99ec9
Fast-forward
 .env       | 1 -
 .gitignore | 1 +
 2 files changed, 1 insertion(+), 1 deletion(-)
 delete mode 100644 .env
 create mode 100644 .gitignore
$ ls -A
.git
.gitignore
go.mod
$ git show ORIG_HEAD:.env > .env
$ git status --short --ignored
!! .env
У коллег файл исчезнет с диска

В истории rm --cached неотличим от обычного удаления: коммит говорит «файла больше нет». Подтягивая такой коммит, git приводит индекс и рабочую копию коллеги к новому дереву и стирает .env с его диска. Были в файле свои правки, и pull откажет с Your local changes to the following files would be overwritten by merge. Предупреди команду заранее. Удалённый файл можно достать из коммита до слияния, как в примере выше через ORIG_HEAD. И помни, что из истории файл никуда не делся: git show 5c3e0d5:.env выведет пароль любому, у кого есть клон. Что делать с утёкшим секретом, разобрано в главе 5.2.

В репозитории без единого коммита не сработает git restore --staged: он берёт версию из HEAD, а ветка, на которую HEAD указывает, ещё не создана, и команда падает с fatal: could not resolve 'HEAD'. Убрать файл из индекса можно через git rm --cached или git reset: на пустой ветке он считает HEAD пустым деревом. git status в этот момент сам подсказывает use "git rm --cached <file>..." to unstage.

Кто куда копирует

Флаги restore разобраны в главе 2.2.

КомандаОткудаКудаHEAD
git addрабочая копияиндекс, блоб сразу в базуна месте
git commitиндексновые tree и commitветка сдвигается
git restoreиндексрабочая копияна месте
git restore --stagedHEADиндексна месте
git switchкоммит веткииндекс и диск, только различающиеся путина другой ветке
git rm --cached-запись удаляется из индексана месте

git reset <коммит> сюда не попал нарочно: он двигает ветку, и его режимы разобраны в главе 5.1. Форма git reset -- путь ветку не трогает и делает то же, что git restore --staged.

Вопросы

4
Суть: индекс лежит в файле .git/index и хранит полный список путей следующего коммита: режим, хеш блоба, данные stat. Это снимок, а не перечень изменений. Благодаря ему коммит можно собрать из части правок, status не перечитывает весь проект, а слияние держит рядом несколько версий конфликтного файла.

Что лежит внутри

Двоичный файл: заголовок с сигнатурой DIRC, записи по одной на файл, отсортированные по пути, расширения и контрольная сумма. Каталогов нет, деревья строятся при коммите. ls-files --stage показывает все файлы проекта, изменённые и нет:

$ git status --short
 M main.go
$ git add main.go
$ git ls-files --stage
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0       README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0       go.mod
100644 527077fd1407ddb47597f2cb96bb242e70d3b108 0       main.go

README.md и go.mod никто не трогал, а записи на месте. Если бы индекс хранил только изменения, их бы там не было. Коммит строится из индекса целиком: git write-tree по этому списку даёт ровно то дерево, которое окажется в коммите.

Три задачи индекса

  • Выбор, что коммитить. Поправил пять файлов, а в коммит хочешь положить два? Добавляешь два, а часть правок внутри файла берёт git add -p (глава 2.1). Без промежуточного снимка атомарный коммит пришлось бы собирать, откатывая лишнее с диска.
  • Скорость. Рядом с хешем лежат время изменения, размер и inode. status сверяет их с lstat и перечитывает только файлы, у которых что-то не совпало.
  • Слияние. При конфликте у пути три записи со стадиями 1, 2 и 3: общий предок и обе стороны (глава 3.2).

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

Считают, что git add помечает файл «для коммита», а в коммит потом уйдёт то, что лежит на диске. На деле add копирует содержимое в момент запуска. Добавил, дописал строку, закоммитил: до коммита статус был MM, после коммита M осталась во второй колонке, и дописанная строка в коммит не попала.

Смотри на индекс, а не на файл

MM в коротком статусе означает, что версия на диске не совпадает с той, что уйдёт в коммит. Перед коммитом полезно открыть git diff --staged: он показывает ровно содержимое будущего коммита. Хочешь закоммитить файл в текущем виде, добавь его ещё раз.

Суть: три команды сравнивают три пары снимков. git diff показывает, чем рабочая копия отличается от индекса, то есть недобавленное. --staged берёт HEAD и индекс: это будущий коммит. git diff HEAD ставит рядом HEAD и диск, всё сразу. Пустой git diff значит только одно: диск совпадает с индексом.

Три пары

КомандаЛевая сторонаПравая сторонаЭквивалент
git diffиндексрабочая копиячто добавит git add изменённых файлов
git diff --staged, --cachedHEADиндексчто запишет git commit
git diff HEADHEADрабочая копиячто запишет git commit -a

Когда в файле есть и добавленная, и недобавленная правка, git diff HEAD показывает их сумму. Приветствие изменили и добавили, константу дописали после: --stat у трёх команд даёт 2 ++, 2 +- и 4 +++-.

Когда git diff молчит

Первый случай: всё уже добавлено. Индекс совпадает с диском, и сравнивать нечего, а правки ждут в --staged:

$ git add main.go
$ git diff
$ git diff --staged --stat
 main.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git status --short
M  main.go

Второй случай: файл новый. Его нет ни в индексе, ни в HEAD, поэтому ни одна из трёх команд его не покажет, даже git diff HEAD. Виден он только в git status как ??. Показать новый файл в diff, не добавляя содержимое, можно через git add -N: в индекс ложится запись с хешем пустого блоба e69de29, и git diff показывает файл целиком как new file.

«Посмотрел diff, там пусто, коммичу»

Пустой git diff ничего не говорит о содержимом коммита. В индексе может лежать забытый отладочный вывод или файл, случайно захваченный git add .. Перед коммитом смотри git diff --staged: только он показывает то, что будет записано.

Суть: правки переезжают с тобой, если затронутые файлы одинаковы в обеих ветках. Файл в ветках разный и в нём есть правки: switch отказывает, ничего не тронув. Неотслеживаемый файл на пути тоже не даст переключиться, а игнорируемый затрётся молча.

Правило для каждого пути

Файл в HEAD и в целевом коммитеЛокальное состояниеЧто сделает switch
одинаковыйлюбоеоставит индекс и диск как есть, правка переедет
разныйправок нетвозьмёт версию из целевой ветки
разныйправка на диске или в индексеоткажет
разныйв индексе уже целевая версияоставит индекс, переключится
есть только в целевомнеотслеживаемый файл с тем же путёмоткажет
есть только в целевомигнорируемый файл с тем же путёмперезапишет без предупреждения

Переехавшие правки git перечисляет строками вида M README.md; добавленные в индекс остаются добавленными.

Неочевидное: сравнивается состояние, а не содержимое

Git не проверяет, пропадёт ли что-то на самом деле. Скопируй в main.go ровно ту версию, что лежит в feature, и switch всё равно откажет: диск отличается от индекса. Добавь файл в индекс, и переключение пройдёт, ведь индекс совпал с целевой версией. Строка M README.md в выводе - несохранённая правка README, она переезжает вместе с переключением:

$ git show feature:main.go > main.go
$ git switch feature
error: Your local changes to the following files would be overwritten by checkout:
        main.go
Please commit your changes or stash them before you switch branches.
Aborting
$ git add main.go
$ git switch feature
M       README.md
Switched to branch 'feature'

Что делать, если switch отказал

  • Правка закончена: закоммить её в текущей ветке.
  • Сырую правку спрячь: git stash, переключение, git stash pop там, где она нужна (глава 2.2).
  • Правку надо унести в другую ветку: git switch -m feature сольёт твою версию с версией ветки. При конфликте файл получит маркеры, статус UU, а в индексе появятся три записи со стадиями 1, 2 и 3; разбор в главе 3.2.
  • Если правка не нужна, git switch --discard-changes feature приведёт индекс и диск к целевой ветке.
Незакоммиченные правки не принадлежат ветке

Индекс и рабочая копия одни на весь репозиторий, ветке принадлежат только коммиты. Начал правку в main, переключился на feature посмотреть код, и правка приехала с тобой; закоммитишь не глядя, и она окажется не в той ветке. Если нужны две ветки сразу, есть git worktree (глава 5.3).

Суть: git rm --cached путь, строка в .gitignore и коммит. У тебя файл остаётся на диске, из следующего дерева он пропадает. У коллег pull этого коммита удалит файл с диска, а в истории файл останется навсегда.

Последовательность

$ echo ".env" >> .gitignore
$ git rm --cached .env              # для каталога: git rm -r --cached .idea
rm '.env'
$ git add .gitignore
$ git commit -m "Не хранить .env в репозитории"
[main 0a99ec9] Не хранить .env в репозитории
 2 files changed, 1 insertion(+), 1 deletion(-)
 delete mode 100644 .env
 create mode 100644 .gitignore

Одного .gitignore мало: на отслеживаемый файл игнор не действует. Вместо настоящего файла в репозитории держат шаблон, например .env.example, а каждый копирует его в игнорируемый .env; так советует и gitfaq.

Что произойдёт у коллег

Для истории это обычное удаление. На pull git переводит индекс и диск коллеги на новое дерево без .env и стирает файл. Если в файле были локальные правки, pull остановится:

$ git status --short
 M .env
$ git pull                          # строки fetch опущены
Updating 5c3e0d5..0a99ec9
error: Your local changes to the following files would be overwritten by merge:
        .env
Please commit your changes or stash them before you merge.
Aborting

Если правок не было, файл пропадёт молча, но вернуть его легко: после pull в ORIG_HEAD записан коммит, на котором ветка стояла до слияния, и git show ORIG_HEAD:.env > .env достанет прежнее содержимое. Раз файл теперь в .gitignore, git его больше не заметит.

Чего делать не надо

Вместо rm --cached иногда советуют git update-index --assume-unchanged или --skip-worktree, чтобы git перестал замечать правки. Документация update-index и gitfaq прямо говорят, что для этого флаги не годятся: часть операций всё равно сверяет файл с индексом, а когда файл поменяется в чужом коммите, слияние на нём споткнётся. Способа «отслеживать, но не замечать правки» в git нет.

rm --cached не убирает секрет из истории

Коммит с удалением ложится поверх старых, а прежние коммиты остаются как были: git show 5c3e0d5:.env выведет DB_PASSWORD=local любому, у кого есть клон. Если в файле был настоящий пароль или токен, считай его утёкшим. Как переписывать историю и почему без смены ключа это не спасает, разобрано в главе 5.2.

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

  • git add копирует выбранное содержимое из working tree в index.
  • git commit записывает index, поэтому правка после add сама не попадёт в коммит.
  • git diff сравнивает working tree с index; --staged сравнивает index с HEAD.

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

  • git restore --staged меняет index, а не рабочую копию.
  • git rm --cached снимает отслеживание, не удаляя локальный файл.

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

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

Чтение кода

1. Найди версию для commit

На диске файл стал C после того, как B попал в index, а HEAD содержит исходник. Прочитай оба diff: какая версия где лежит?

$ git diff
diff --git a/main.go b/main.go
index c2e2af8..d572b22 100644
--- a/main.go
+++ b/main.go
@@ -1,3 +1,3 @@
 package main

-func main() { println("B") }
+func main() { println("C") }
$ git diff --staged
diff --git a/main.go b/main.go
index 38dd16d..c2e2af8 100644
--- a/main.go
+++ b/main.go
@@ -1,3 +1,3 @@
 package main

-func main() {}
+func main() { println("B") }
Ответ и разбор

git diff показывает рабочую копию C против index B; git diff --staged показывает index B против HEAD, где был исходный файл. Поэтому следующий commit запишет B. Чтобы включить C, снова выполни add после последней правки.

Код-ревью

2. Не затри локальную правку

README содержит незакоммиченные правки, но инструкция предлагает:

git restore README.md

Что будет перезаписано и как безопасно снять только staging?

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

Без --staged команда копирует версию index в working tree и уничтожит незастейдженные изменения. Проверь git diff и сохрани нужные правки. Чтобы снять только staging, используй git restore --staged README.md.

Применение

3. Сними staging одного файла

В index случайно попал secrets.txt, но локальный файл нужен для работы. Как оставить его на диске и убрать из следующего commit?

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

Используй git rm --cached secrets.txt, добавь правило ignore и проверь git status и git diff --cached --name-status. Если файл раньше публиковали, отдельно отзови секрет: удаление из текущего индекса не стирает старую историю.

Применение

4. Разбери отказ switch

Текущая и целевая ветки меняют одну строку config.yaml по-разному. В working tree есть незакоммиченная правка этой строки. Почему switch может отказаться и как сохранить работу?

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

Переключение должно заменить файл версией целевой ветки и затёрло бы локальную правку, поэтому Git отказывается. Закоммить её, сохрани в stash или вынеси в ветку. Не используй принудительное переключение, пока не проверил, что содержимое можно потерять.