Как отфильтровать историю на основе gitignore?
Чтобы прояснить этот вопрос, я не спрашиваю о том, как удалить один файл из истории, как этот вопрос: Полное удаление файла из всей истории фиксации репозитория Git. Я также не спрашиваю об отслеживании файлов из gitignore, как в этом вопросе: игнорировать файлы, которые уже были переданы в репозиторий Git.
Я говорю об "обновлении файла.gitignore и последующем удалении всего, что соответствует списку из истории", более или менее как этот вопрос: игнорировать файлы, которые уже были переданы в репозиторий Git. Однако, к сожалению, ответ на этот вопрос не работает для этой цели, поэтому я здесь, чтобы попытаться разработать вопрос и, надеюсь, найти хороший ответ, который не требует, чтобы человек просматривал все дерево исходных текстов, чтобы вручную выполнить ветвь фильтра. на каждый соответствующий файл.
Здесь я предоставляю тестовый скрипт, который в настоящее время выполняет процедуру в ответе на Ignore файлы, которые уже были переданы в Git-репозиторий. Собирается удалить и создать папку root
под PWD, так что будьте осторожны, прежде чем запускать его. Я опишу свою цель после кода.
#!/bin/bash -e
TESTROOT=${PWD}
GREEN="\e[32m"
RESET="\e[39m"
rm -rf root
mkdir -v root
pushd root
mkdir -v repo
pushd repo
git init
touch a b c x
mkdir -v main
touch main/{a,x,y,z}
# Initial commit
git add .
git commit -m "Initial Commit"
echo -e "${GREEN}Contents of first commit${RESET}"
git ls-files | tee ../00-Initial.txt
# Add another commit just for demo
touch d e f y z main/{b,c}
## Make some other changes
echo "Test" | tee a | tee b | tee c | tee x | tee main/a > main/x
git add .
git commit -m "Some edits"
echo -e "${GREEN}Contents of second commit${RESET}"
git ls-files | tee ../01-Changed.txt
# Now I want to ignore all 'a' and 'b', and all 'main/x', but not 'main/b'
## Checkout the root commit
git checkout -b temp $(git rev-list HEAD | tail -1)
## Add .gitignores
echo "a" >> .gitignore
echo "b" >> .gitignore
echo "x" >> main/.gitignore
echo "!b" >> main/.gitignore
git add .
git commit --amend -m "Initial Commit (2)"
## --v Not sure if it is correct
git rebase --onto temp master
git checkout master
## --v Now, why should I delete this branch?
git branch -D temp
echo -e "${GREEN}Contents after rebase${RESET}"
git ls-files | tee ../02-Rebased.txt
# Supposingly, rewrite history
git filter-branch --tree-filter 'git clean -f -X' -- --all
echo -e "${GREEN}Contents after filter-branch${RESET}"
git ls-files | tee ../03-Rewritten.txt
echo "History of 'a'"
git log -p a
popd # repo
popd # root
Этот код создает репозиторий, добавляет некоторые файлы, делает некоторые изменения и выполняет процедуру очистки. Кроме того, некоторые файлы журнала генерируются. В идеале хотелось бы a
, b
, а также main/x
исчезают из истории, а main/b
остается. Однако прямо сейчас ничего не удалено из истории. Что следует изменить для достижения этой цели?
Бонусные баллы, если это можно сделать на нескольких ветках. Но пока держите его в одной основной ветке.
2 ответа
Достичь желаемого результата немного сложно. Самый простой способ, используя git filter-branch
с --tree-filter
будет очень медленным. Изменить: я изменил ваш пример сценария, чтобы сделать это; см. конец этого ответа.
Во-первых, отметим одно ограничение: вы никогда не сможете изменить какой-либо существующий коммит. Все, что вы можете сделать, это сделать новые коммиты, которые очень похожи на старые, но "новые и улучшенные". Затем вы приказываете Git перестать смотреть на старые коммиты и смотреть только на новые. Это то, что мы будем делать здесь. (Затем, если требуется, вы можете заставить Git действительно забыть старые коммиты. Самый простой способ - это клонировать клон.)
Теперь, чтобы повторно зафиксировать каждый коммит, доступный по одному или нескольким именам ветвей и / или тегов, сохранив все, кроме того, что мы явно указали для изменения,1 мы можем использовать git filter-branch
, Команда filter-branch имеет довольно головокружительный массив параметров фильтрации, большинство из которых предназначены для ускорения работы, потому что копирование каждого коммита происходит довольно медленно. Если в репозитории всего несколько сотен коммитов, каждый из которых содержит несколько десятков или сотен файлов, это не так уж плохо; но если существует около 100000 коммитов, каждый из которых содержит около 100000 файлов, это десять тысяч миллионов файлов (10 000000000 файлов) для проверки и повторной фиксации. Это займет некоторое время.
К сожалению, нет простого и удобного способа ускорить это. Лучший способ ускорить его - использовать --index-filter
, но нет встроенной команды фильтра индекса, которая будет делать то, что вы хотите. Самый простой фильтр для использования --tree-filter
, который также является самым медленным из существующих. Возможно, вы захотите поэкспериментировать с написанием собственного фильтра индекса, возможно, в сценарии оболочки или на другом языке, который вы предпочитаете (вам все равно придется вызывать git update-index
в любом случае).
1 Подписанные аннотированные метки не могут быть сохранены без изменений, поэтому их подписи будут удалены. Подписанные коммиты могут иметь свои подписи недействительными (если хеш коммита изменяется, что зависит от того, должно ли оно: помнить, что хэш-идентификатор коммита является контрольной суммой содержимого коммита, поэтому, если набор файлов изменяется, контрольная сумма изменяется; но если контрольная сумма родительского коммита изменяется, контрольная сумма этого коммита также изменяется).
С помощью --tree-filter
Когда вы используете git filter-branch
с --tree-filter
код ветки фильтра извлекает каждую фиксацию по одной во временный каталог. Этот временный каталог не имеет .git
каталог и не там, где вы работаете git filter-branch
(это на самом деле в подкаталоге .git
каталог, если вы не используете -d
возможность перенаправить Git, скажем, в файловую систему памяти, что является хорошей идеей для его ускорения).
После извлечения всего коммита во этот временный каталог Git запускает ваш древовидный фильтр. Когда ваш древовидный фильтр завершится, Git упаковывает все в этом временном каталоге в новый коммит. Все, что вы оставляете там, находится внутри. Все, что вы добавляете туда, добавляется. Все, что вы там измените, будет изменено. Все, что вы удалите оттуда, больше не будет в новом коммите.
Обратите внимание, что .gitignore
файл в этом временном каталоге не влияет на то, что будет зафиксировано (но .gitignore
Сам файл будет зафиксирован, поскольку все, что находится во временном каталоге, становится новым copy-commit). Так что, если вы хотите быть уверены, что файл какого-либо известного пути не зафиксирован, просто rm -f known/path/to/file.ext
, Если файл находился во временном каталоге, он исчез. Если нет, то ничего не происходит и все хорошо.
Следовательно, работающий фильтр дерева будет:
rm -f $(cat /tmp/files-to-remove)
(при условии отсутствия пробелов в именах файлов; используйте xargs ... | rm -f
чтобы избежать проблем с пробелами, с любой кодировкой, которую вы любите для ввода xargs; -z
стиль кодирования идеален, так как \0
запрещено в именах путей).
Преобразование этого в индексный фильтр
Использование индексного фильтра позволяет Git пропустить фазы извлечения и изучения. Если бы у вас был фиксированный список "удалить" в нужной форме, его было бы легко использовать.
Допустим, у вас есть имена файлов в /tmp/files-to-remove
в форме, которая подходит для xargs -0
, Ваш индексный фильтр может затем прочитать полностью:
xargs -0 /tmp/files-to-remove | git rm --cached -f --ignore-unmatch
который в основном такой же, как rm -f
выше, но работает во временном индексе, который Git использует для каждого коммита, подлежащего копированию. (Добавлять -q
к git rm --cached
чтобы было тихо.)
применение .gitignore
файлы в дереве фильтра
Ваш пример сценария пытается использовать --tree-filter
после перебазирования на начальный коммит, в котором есть нужные элементы:
git filter-branch --tree-filter 'git clean -f -X' -- --all
Хотя есть одна начальная ошибка (git rebase
неправильно):
-git rebase --onto temp master
+git rebase --onto temp temp master
Исправляя это, вещь все еще не работает, и причина в том, что git clean -f -X
удаляет только те файлы, которые фактически игнорируются. Любой файл, который уже есть в индексе, фактически не игнорируется.
Хитрость заключается в том, чтобы очистить индекс. Тем не менее, это слишком много: git clean
затем никогда не спускается в подкаталоги - поэтому хитрость состоит из двух частей: очистить индекс, а затем снова заполнить его не игнорируемыми файлами. Сейчас git clean -f -X
удалит оставшиеся файлы:
-git filter-branch --tree-filter 'git clean -f -X' -- --all
+git filter-branch --tree-filter 'git rm --cached -qrf . && git add . && git clean -fqX' -- --all
(Я добавил несколько "тихих" флагов здесь).
Чтобы избежать необходимости перезагружать в первую очередь для установки начального .gitignore
файлы, скажем, у вас есть основной набор .gitignore
файлы, которые вы хотите в каждом коммите (который мы затем будем использовать в древовидном фильтре). Просто поместите их, и ничего больше, во временное дерево:
mkdir /tmp/ignores-to-add
cp .gitignore /tmp/ignores-to-add
mkdir /tmp/ignores-to-add/main
cp main/.gitignore /tmp/ignores-to-add
(Я оставлю работать над сценарием, который находит и копирует только .gitignore
файлы для вас, кажется, умеренно раздражает обойтись без одного). Тогда для --tree-filter
, используйте:
cp -R /tmp/ignores-to-add . &&
git rm --cached -qrf . &&
git add . &&
git clean -fqX
Первый шаг, cp -R
(что может быть сделано в любом месте до git add .
действительно, устанавливает правильные .gitignore
файлы. Так как мы делаем это с каждым коммитом, нам никогда не нужно перезагружать перед запуском filter-branch
,
Второй удаляет все из индекса. (Немного более быстрый метод просто rm $GIT_INDEX_FILE
но не гарантируется, что это будет работать вечно.)
Третий повторно добавляет .
все во временном дереве. Так как .gitignore
файлы на месте, мы добавляем только игнорируемые файлы.
Последний шаг, git clean -qfX
, удаляет файлы рабочего дерева, которые игнорируются, так что filter-branch
не верну их.
На окнах эта последовательность не работает для меня:
cp -R /tmp/ignores-to-add . &&
git rm --cached -qrf . &&
git add . &&
git clean -fqX
Но следующие работы.
Обновите каждый коммит с существующим.gitignore:
git filter-branch --index-filter '
git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q
' -- --all
Обновите.gitignore в каждом коммите и фильтруйте файлы:
cp ../.gitignore /d/tmp-gitignore
git filter-branch --index-filter '
cp /d/tmp-gitignore ./.gitignore
git add .gitignore
git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q
' -- --all
rm /d/tmp-gitignore
использованиеgrep -v
если у вас были особые случаи, например, файл empty
сохранить пустой каталог:
git ls-files -i --exclude-from=.gitignore | grep -vE "empty$" | xargs git rm --cached -q
Этот метод заставляет git полностью забыть игнорируемые файлы (прошлые/ настоящие / будущие), но не удаляет ничего из рабочего каталога (даже при повторном извлечении с удаленного компьютера).
Этот метод требует использования
/.git/info/exclude
(предпочтительный) ИЛИранее существовавших.gitignore
во всех коммитах, в которых есть файлы, которые нужно игнорировать / забыть. 1Все методы принудительного применения git игнорируют поведение постфактум эффективно переписывают историю и, таким образом, имеют значительные разветвления для любых публичных / общих / совместных репозиториев, которые могут быть извлечены после этого процесса. 2
Общий совет: начните с чистого репо - все зафиксировано, ничего не ожидает в рабочем каталоге или индексе, и сделайте резервную копию!
Кроме того, комментарии / история изменений из этого ответа ( и истории изменений по этому вопросу), может быть полезной / просвещая.
#commit up-to-date .gitignore (if not already existing)
#this command must be run on each branch
git add .gitignore
git commit -m "Create .gitignore"
#apply standard git ignore behavior only to current index, not working directory (--cached)
#if this command returns nothing, ensure /.git/info/exclude AND/OR .gitignore exist
#this command must be run on each branch
git ls-files -z --ignored --exclude-standard | xargs -0 git rm --cached
#Commit to prevent working directory data loss!
#this commit will be automatically deleted by the --prune-empty flag in the following command
#this command must be run on each branch
git commit -m "ignored index"
#Apply standard git ignore behavior RETROACTIVELY to all commits from all branches (--all)
#This step WILL delete ignored files from working directory UNLESS they have been dereferenced from the index by the commit above
#This step will also delete any "empty" commits. If deliberate "empty" commits should be kept, remove --prune-empty and instead run git reset HEAD^ immediately after this command
git filter-branch --tree-filter 'git ls-files -z --ignored --exclude-standard | xargs -0 git rm -f --ignore-unmatch' --prune-empty --tag-name-filter cat -- --all
#List all still-existing files that are now ignored properly
#if this command returns nothing, it's time to restore from backup and start over
#this command must be run on each branch
git ls-files --other --ignored --exclude-standard
Наконец, следуйте остальной части этого руководства GitHub (начиная с шага 6), которое включает важные предупреждения / информацию о командах ниже.
git push origin --force --all
git push origin --force --tags
git for-each-ref --format="delete %(refname)" refs/original | git update-ref --stdin
git reflog expire --expire=now --all
git gc --prune=now
Другие разработчики, использующие теперь измененное удаленное репо, должны сделать резервную копию, а затем:
#fetch modified remote
git fetch --all
#"Pull" changes WITHOUT deleting newly-ignored files from working directory
#This will overwrite local tracked files with remote - ensure any local modifications are backed-up/stashed
#Switching branches after this procedure WILL LOOSE all newly-gitignored files in working directory because they are no longer tracked when switching branches
git reset FETCH_HEAD
Сноски
1 Потому что/.git/info/exclude
можно применить ко всем историческим коммитам, используя приведенные выше инструкции, возможно, подробности о получении .gitignore
файл в историческую фиксацию (и), которые нуждаются в этом, выходит за рамки этого ответа. Я хотел правильный.gitignore
быть в корневом коммите, как будто это было первое, что я сделал. Другим может быть все равно, так как/.git/info/exclude
может сделать то же самое независимо от того, где .gitignore
существует в истории коммитов, и очевидно, что переписывание истории - очень щекотливая тема, даже если вы знаете о разветвлениях.
FWIW, потенциальные методы могут включать git rebase
или git filter-branch
который копирует внешний .gitignore
в каждый коммит, как и ответы на этот вопрос
2 Принудительное использование git ignore поведения постфактум путем фиксации результатов автономнойgit rm --cached
Команда может привести к удалению недавно проигнорированного файла в будущих запросах с принудительно нажатого пульта ДУ. В--prune-empty
флаг в следующих git filter-branch
команда позволяет избежать этой проблемы, автоматически удаляя предыдущую фиксацию "удалить все игнорируемые файлы" только для индекса. Переписывание истории git также изменяет хэши коммитов, что нанесет ущерб будущим запросам из общедоступных / общих / совместных репозиториев. Пожалуйста, полностью осознайте последствия, прежде чем делать это для такого репо. В этом руководстве GitHub указывается следующее:
Скажите своим соавторам, чтобы они перебазировали, а не объединяли любые ветки, которые они создали из вашей старой (испорченной) истории репозитория. Одна фиксация слияния может повторно ввести часть или всю испорченную историю, которую вы только что потрудились очистить.
Альтернативные решения, не влияющие на удаленное репо:git update-index --assume-unchanged </path/file>
или git update-index --skip-worktree <file>
, примеры которых можно найти здесь.