Как отфильтровать историю на основе 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>, примеры которых можно найти здесь.

Другие вопросы по тегам