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/objects | tree и blob последнего коммита, неизменяемые | git ls-tree -r HEAD, git show HEAD:main.go |
| Индекс | один файл .git/index | плоский список: путь, режим, хеш блоба, данные stat | git ls-files --stage, git show :main.go |
| Рабочая копия | каталог проекта | обычные файлы; git читает их, только когда его попросят | редактор, ls, cat |
Запись :main.go (двоеточие и путь без имени коммита) указывает на версию файла в индексе.
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 HEAD | HEAD → рабочая копия | что уйдёт в коммит при 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: файл в ветках одинаков, и его оставили как был.
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.
На файлы из .gitignore эта защита не действует: git считает их
расходным материалом. Пусть handler.go с черновиком у тебя игнорируется, а в
feature файл с тем же путём закоммичен. После git switch feature на
диске окажется версия из ветки, без ошибки и без предупреждения. Так легко лишиться локального
конфига, который в текущей ветке игнорируется, а в старой ещё лежит в репозитории. С флагом
--no-overwrite-ignore switch вместо перезаписи откажет. В справке switch этого флага
нет, он описан у git checkout, но 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 --staged | HEAD | индекс | на месте |
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, --cached | HEAD | индекс | что запишет git commit |
git diff HEAD | HEAD | рабочая копия | что запишет 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.
Пустой git diff ничего не говорит о содержимом коммита. В индексе может
лежать забытый отладочный вывод или файл, случайно захваченный git add ..
Перед коммитом смотри git diff --staged: только он показывает то, что будет
записано.
Правило для каждого пути
| Файл в 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 нет.
Коммит с удалением ложится поверх старых, а прежние коммиты остаются как были:
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 или вынеси в ветку. Не используй принудительное переключение, пока не проверил, что содержимое можно потерять.