git filter-branch-удаление изменений в наборе файлов в диапазоне коммитов


Скажем, у меня есть ветвь dev, и я хочуотбросить все изменения, внесенныев набор файлов в ярости коммитов в ветви dev, поскольку она отличается от master. Если фиксация в этом диапазоне касается только этих файлов, я бы хотел, чтобы она была обрезана. Самое близкое, что я получил, было:

git checkout dev
git filter-branch --force --tree-filter 'git checkout master -- 
a/b/c.png 
...
' --prune-empty -- master-dev-older-ancestor..HEAD

Но у этого есть эти недостатки

  1. если файл был с тех пор удален в master, он не будет работать с error: pathspec 'a/b/c.png' did not match any file(s) known to git. я мог бы решить git checkout master-dev-older-ancestor, но тогда,
  2. этот файл может не существовать в мастер-Дев-старший-предок, и был слит с мастером обратно в dev в более поздний момент
  3. в конце концов, я могу отказаться от изменений в некоторых файлах, которые нигде не видны в master
Суть в том, что я не хочу, чтобы git проверял конкретную версию файла - я хочу, чтобы git фильтровал все коммиты в диапазоне master-dev-older-ancestor..HEAD иметь все изменения в произвольном наборе файлов (присутствует в любом месте на master или нет ) отброшено.

Так как же мне сказать git ?

1 4

1 ответ:

В принципе, то, что делает ветвь фильтра, - это все остальное-оптимизация и / или граничные случаи:1

  • для каждого коммита в перечисленных ревизиях):
    1. проверьте эту фиксацию;
    2. применить фильтр (ы);
    3. создайте новый коммит, который может быть или не быть таким же, как старый коммит, в зависимости от шага 2 (т. е. эта новая копия является модифицированной версией старого, если она не идентична биту за бит, и в этом случае "созданный новый" коммит является на самом деле просто старый коммит в конце концов).
  • для каждого "положительного" ref в командной строке перепишите его так, чтобы он указывал на новый коммит, сделанный на Шаге 3, где бы он не указывал на старый коммит, извлеченный на шаге 1.
Теперь давайте рассмотрим ваше желаемое действие, но я подчеркну другое слово:

Фильтровать все коммиты в диапазоне [a]... иметь все изменения в произвольном наборе файлов ... отброшено

I подчеркните здесь "изменения", потому что каждая фиксация является полной, автономной сущностью. Коммиты не имеют "изменения", они просто имеют файлы. Единственный способ увидеть изменения-сравнить один конкретный коммит с другим конкретным коммитом: git diff commitA commitB например.

Таким образом, когда вы говорите "изменения в каком-то файле(файлах)", непосредственным очевидным вопросом должен быть: изменения относительно чего?

В большинстве случаев люди, которые говорят об "изменениях в фиксации", имеют в виду "изменения в этот коммит по отношению к своему непосредственному предку": для простых (не слияние) коммитов, патч, который вы получите с git show или git log -p. (Обычно они не задумываются о том, что они имеют в виду, если коммит является слиянием и, следовательно, имеет несколько родителей. Для них git show обычно показывает комбинированное различие коммита слияния против всех его родителей, но это может не соответствовать намерению пользователя здесь; смотрите документацию git-show для получения подробной информации.)

При использовании git filter-branch, вы должны будете определить это (изменения по отношению к чему) вы сами. Команда filter-branch дает вам идентификатор SHA-1 извлеченного коммита-даже если он только" виртуально " извлечен на шаге 1, а не фактически помещен в дерево на диске-в переменной окружения $GIT_COMMIT. Итак, если ваше определение "по отношению к тому, что" является "по отношению к первому родителю", вы можете использовать gitrevisions синтаксис для ссылки на родителя: ${GIT_COMMIT}^ является первым родителем, даже если ${GIT_COMMIT} является необработанным SHA-1.

Очень грубый и неоптимизированный --tree-filter , который просто извлекает родительские версии каждого такого файла, выглядит следующим образом:2

for path in ...list-of-paths...; do
    git checkout -q ${GIT_COMMIT}^ -- $path 2>/dev/null
done
exit 0 # in case the last "git checkout" failed, override its status

, который просто просит git получить версию родительского коммита файла, отбрасывая любое сообщение об ошибке, возникающее из-за того, что файл не существует в родительской версии. Но это также может не соответствовать вашим намерениям: неясно, хотите ли вы удалить файл, если его нет в Родительском файле. Более того, если файл добавляется или удаляется где-то в последовательности коммиты в вашем диапазоне, сравнение каждого исходного коммита только с его (единственным) исходным родительским коммитом может привести к ошибкам. Например, если файл foo не существует в commit C5, существует в C6 и остается неизменным в C7, сравнение между C7 и C6 говорит "файл без изменений", в то время как более раннее сравнение C5-C6 говорит "файл добавлен". Если ваш новый (измененный) C6 - назовем его C6', чтобы отличить их друг от друга-удаляет foo, потому что его не было в C5, вероятно, ваш C7' также должен опустить файл foo.

Другой альтернативой является сравнение каждого коммита с (единственным) коммитом непосредственно перед всего диапазона. Если ваш диапазон охватывает коммиты C1, C2, C3,..., C9, мы можем назвать единственный предыдущий коммит C0. Тогда вместо сравнения C1 с C1^, C2 с C2^ и так далее, мы можем сравнить C1 с C0, C2 с C0, C3 с C0 и так далее. В зависимости от вашего определения "изменений", это может быть именно то, что вы хотите, потому что "отмена изменений" может быть транзитивной: мы удаляем foo в нашем новом С6, следовательно, мы должны удалить foo и в нашем новом С7; мы добавляем обратно bar в новом С7, следовательно, мы должны добавить его обратно в новом С8, и так далее.

Менее грубая версия сценария сравнения выглядит следующим образом (это может быть оптимизировано и для --index-filter, хотя я оставлю работу кому-то другому, поскольку это предназначено для иллюстрации):

# Note: I haven't tested this either, not sure how it behaves if
# used inside git filter-branch.  As a --tree-filter you would not
# really want to "git rm" anything, just to "rm" it.  As an
# --index-filter you would want to "git rm --cached".  For
# checkout, as a tree filter you want to extract the file into
# the working tree, and as an index filter you want to extract
# the file into the index.
git diff --name-status --no-renames $WITH_RESPECT_TO $GIT_COMMIT \
    -- ...paths... |
while read status path; do
    # note: $path may have embedded white space, so we
    # quote it below to protect it from breaking into words
    case $status in
    A) git rm -- "$path";; # file was added, rm it to undo
    D|M) git checkout $WITH_RESPECT_TO -- "$path";; # deleted or modified
    *) echo "file $path has strange status $status, help!" 1>&2; exit 1;;
    esac
done

Пояснение: вышеизложенное предполагает, что вы фильтруете (возможно, линейный, возможно, ветвящийся) ряд коммиты C1, C2, ..., Cn. Вы хотите, чтобы они "не изменяли содержание или даже существование" некоторого набора путей, относительно некоторого родительского из-C1 фиксации. Необходимо задать соответствующий спецификатор в $WITH_RESPECT_TO. (Это может исходить из окружающей среды или просто быть жестко закодировано в реальный сценарий. Обратите внимание, что для вашего --index-filter или --tree-filter, вы можете заставить оболочку запустить сценарий, а не пытаться сделать все это в строке.)

Например, если вы фильтруете X..Y, что означает " все коммиты reachable from label Y исключая все коммиты, достижимые из label X", возможно, что соответствующее значение для $WITH_RESPECT_TO просто X, но более вероятно, что это база слияния X и Y. Если X и Y являются ветвями, которые выглядят примерно так:

...-o-o-o-o-o-o   <-- master
     \
      *-o-o       <-- X
       \
        o-o-o-o   <-- Y

Затем вы фильтруете коммиты в нижней строке, и первый коммит, который будет отфильтрован, вероятно, должен быть " неизменен относительно некоторых путей, как показано в commit * "(коммит, который я отметил звездочка). Это коммит, который git merge-base X Y придумал бы.

Если вы работаете с raw SHA-1 id, вы можете использовать что-то вроде:

WITH_RESPECT_TO=676699a0e0cdfd97521f3524c763222f1c30a094 \
git filter-branch ... (filter-branch arguments go here) ... --
676699a0e0cdfd97521f3524c763222f1c30a094..branch

Где raw SHA-1-это идентификатор фиксации *, так сказать.

Что касается самого git diff, давайте посмотрим, какой вид выходных данных он производит:

$ git diff --name-status --no-renames \
>  2cd861672e1021012f40597b9b68cc3a9af62e10 \
>  7bbc4e8fdb33e0a8e42e77cc05460d4c4f615f4d
M       Documentation/RelNotes/1.8.5.4.txt
A       Documentation/RelNotes/1.8.5.5.txt
M       Documentation/git.txt
M       GIT-VERSION-GEN
M       RelNotes

(это фактический вывод git diff на исходное дерево для самого git). Между этими двумя редакциями был изменен один текстовый файл release-notes, один был добавлен, Documentation/git.txt был изменен, и так далее. Теперь давайте попробуем еще раз, но ограничимся одним реальным именем пути и одним поддельным:

$ git diff --name-status --no-renames \
>  2cd861672e1021012f40597b9b68cc3a9af62e10 \
>  7bbc4e8fdb33e0a8e42e77cc05460d4c4f615f4d \
>  -- Documentation/RelNotes/1.8.5.5.txt NoSuchFile
A       Documentation/RelNotes/1.8.5.5.txt
Теперь мы узнаем об одном добавленном файле, но нет никаких жалоб на несуществующий файл. Таким образом, это нормально, чтобы дать "несуществующие" пути; они просто не будут встречаться в выходных данных.

Если diffing commit $WITH_RESPECT_TO против некоторого более позднего commit C говорит, что путь p добавляется в commit C, мы знаем, что это не так. существует в $WITH_RESPECT_TO и существует в C, поэтому мы хотим удалить его, чтобы он был "неизменным". (Это относится к статусной букве A.)

Если разница говорит, что путь p удаляется в C, мы знаем, что он существует в первом и должен быть восстановлен, чтобы остаться "неизменным". (Это относится к статусной букве D.)

Если разница говорит, что путь p существует в обоих, но содержимое файла отличаются друг от друга C, содержание должно быть восстановлено, чтобы остаться "неизменным". (Это относится к статусной букве M.)

Другие буквы статуса diff:C, R, T, U, X, и B, но некоторые не могут произойти (мы исключаем C, R, и B путем указания соответствующих git diff параметров; U происходит только при неполных слияниях; и X не должно происходить: см. что означают статусы Git "разбитое сопряжение" и "неизвестный" и когда они происходят?). Случай T может привести к прерыванию фильтрации (например, обычный файл заменяется символьной ссылкой или наоборот; или что-то заменяется подмодулем).


Если, поразмыслив некоторое время над этим вопросом, вы решите, что "по отношению к" следует использовать родительский коммит(ы), вы можете использовать git diff-tree, который-при наличии одного коммита-сравнивает дерево коммита с деревьями его родителей. (Но снова обратите внимание на его поведение при коммитах слияния и убедитесь, что вот чего ты хочешь.)

1 При использовании --tree-filter, он фактически делает полномасштабную часть проверки всего. С помощью --index-filter он записывает фиксацию в индекс, но не в файловую систему, и позволяет вам вносить все изменения в индекс. С помощью --env-filter, --msg-filter, --parent-filter, и --commit-filter, он позволяет изменять текст, автора и/или родителей каждого коммита. --tag-name-filter позволяет изменить имена тегов, если это необходимо, и заставляет новые имена указывать на новые фиксации вместо старых (следовательно, --tag-name-filter cat оставляет имена неизменными и делает те, которые указывали на старые коммиты, теперь указывают на новые).

--prune-empty покрывает крайний случай: если у вас есть цепочка коммитов C1 <- C2 <- C3, и ваша C2' (ваша копия C2) имеет то же самое базовое дерево, что и ваша C1', сравнение деревьев C2' и C1' приводит к пустому различию. Операция ветвления фильтра обычно сохраняет их, но пропускает их, если вы используете --prune-empty: Ваша новая цепочка будет C1' <- C3'. Но обратите внимание, что исходная цепочка может иметь "пустые" коммиты; в этом случае filter-branch будет обрезать их, даже если копии фактически совпадают с оригиналами.

2 Эти сценарии написаны как бы в файлах сценариев. Если вы превратите их в однострочные, вам нужно будет добавить точки с запятой по мере необходимости, а также, возможно, превратить exit в return, так как вы не хотите, чтобы все это завершилось, когда evaled.