重置树(一个提交/分支及其所有子项)的基准。

22

这是我的当前 git 代码库:

A - H (master)
|
\- B - C - D - E (feature)
           |
           \- F (new)
           |
           \- G (other)

我希望将侧分支的基础转移到H而不是A上:

A - H (master)
    |
    \- B'- C'- D'- E'(feature)
               |
               \- F'(new)
               |
               \- G'(other)

这是一个简单的概念,但似乎很难自动完成。这个问题已经在这里这里提问过了,但是建议的解决方案对我来说并不起作用。

首先,正如前者所指出的那样,当当前分支存在时(前面有一个“*”),git branch 的输出不容易解析。但这不是一个障碍,在我的情况下我可以手动提供名称 featurenewother,或者确保当前分支是 master

然后我尝试了这些命令:

git rebase --committer-date-is-author-date --preserve-merges --onto master feature^ feature
git rebase --committer-date-is-author-date --preserve-merges --onto master feature^ new
git rebase --committer-date-is-author-date --preserve-merges --onto master feature^ other

最终结果是:

A - H (master)
    |
    \- E'(feature)
    |
    \- B' - C' - D' - F'(new)
    |
    \- B" - C" - D" - G'(other)

绝对不是我想要的!或者,如果我使用B^代替feature^,那么我也会在feature分支中得到B-C-D历史记录。

那么,有没有更多的建议可以自动完成这个任务呢?

编辑:使用以下方法可以部分解决问题:

git checkout feature
git merge other
git merge new
git rebase -p master feature

现在至少树看起来是正确的,我只需要在合并之前将分支头移动到它们正确的提交位置...这可以通过以下命令完成:

git checkout master
git branch -f new feature^2
git branch -f feature feature^1
git branch -f other feature^2
git branch -f feature feature^1

@kostix 是的,也许吧。但显然另一个问题涉及不同的树。我尝试了那里的解决方案,但我得到了一棵重复的树(尽管结构看起来没问题)。 - Jellby
@Jellby:你是否考虑过将主分支上的新提交“H”合并到特性分支中,而不是变基? - Arjan
@arjan 是的,我有...但是我必须将主分支与每个分支合并,这会使历史记录复杂化,并且在侧分支中检出旧提交时会错过H中的更改,并且合并之前和之后的提交之间的差异将包括master中的所有更改。对于我正在进行的开发(大多数与master中发生的事情分开),我认为变基更有意义。 - Jellby
@useless 这正是我所说的我尝试过的(这个:https://dev59.com/WG035IYBdhLWcg3wJccv#5632027),并展示了我的结果。 - Jellby
请参见我的问题(和答案):http://stackoverflow.com/questions/21427060/removing-a-specific-commit-in-the-git-history-with-several-branches。虽然标题可能不同,但我认为它实际上是相同的。 - Simon
显示剩余2条评论
4个回答

8
在这些情况下,一个好的技巧是将所有需要移动的分支合并(join)到一个最终提交节点中。之后,使用rebase命令,并添加--preserve-merges选项来移动所得到的封闭子树(一组分支)。
创建一个包含所有分支的封闭子树,会暴露出两个节点(起始和结束),这些节点被用作rebase命令的输入参数。
封闭子树的末端是一个人工节点,可以在移动树之后删除,其他可能为合并其他分支而创建的节点也可以删除。
让我们看看以下情况。
开发人员希望将一个新的提交(master)插入到其他3个开发分支(b11、b2、b3)中。其中一个分支(b11)是基于b1的一个特性分支b12的合并。另外两个分支(b2、b3)则是分叉的。
当然,开发人员可以将该新提交cherry-pick到每个分支中,但开发人员可能更喜欢在分支分叉之前只有1个提交而不是在3个不同的分支中都有相同的提交。
* baa687d (HEAD -> master) new common commit
| * b507c23 (b11) b11
| *   41849d9 Merge branch 'b12' into b11
| |\
| | * 20459a3 (b12) b12
| * | 1f74dd9 b11
| * | 554afac b11
| * | 67d80ab b11
| |/
| * b1cbb4e b11
| * 18c8802 (b1) b1
|/
| * 7b4e404 (b2) b2
| | * 6ec272b (b3) b3
| | * c363c43 b2 h
| |/
| * eabe01f header
|/
* 9b4a890 (mirror/master) initial
* 887d11b init

为此,第一步是创建一个包含3个分支的公共合并提交。为此使用临时分支pack

合并到pack可能会产生冲突,但这并不重要,因为这些合并将在以后被丢弃。只需指示git自动解决它们,添加选项-s recursive -Xours

$ git checkout -b pack b11 # create new branch at 'b11' to avoid losing original refs
$ git merge -s recursive -Xours b2 # merges b2 into pack
$ git merge -s recursive -Xours b3 # merges b3 into pack

这是将所有内容合并到pack分支后的完整树形结构:
*   b35a4a7 (HEAD -> pack) Merge branch 'b3' into pack
|\
| * 6ec272b (b3) b3
| * c363c43 b2 h
* |   60c9b7c Merge branch 'b2' into pack
|\ \
| * | 7b4e404 (b2) b2
| |/
| * eabe01f header
* | b507c23 (b11) b11
* |   41849d9 Merge branch 'b12' into b11
|\ \
| * | 20459a3 (b12) b12
* | | 1f74dd9 b11
* | | 554afac b11
* | | 67d80ab b11
|/ /
* | b1cbb4e b11
* | 18c8802 (b1) b1
|/
| * baa687d (master) new common commit
|/
* 9b4a890 initial
* 887d11b init

现在是时候移动已创建的子树了。为此,使用以下命令:
$ git rebase --preserve-merges --onto master master^ pack

参考文献master^表示master之前的提交(master的父提交),在本例中为9b4a890。该提交未被重新基于,它是3个被基于的分支的起点。当然,pack是整个子树的最终参考。
在重新基于过程中可能会出现一些合并冲突。如果在合并之前已经存在冲突,则这些冲突将再次出现。请确保以相同的方式解决它们。对于为了合并到临时节点pack而创建的人工提交,请不要费心,自动解决它们即可。
重新基于后,将得到以下结果树:
*   95c8d3d (HEAD -> pack) Merge branch 'b3' into pack
|\
| * d304281 b3
| * ed66668 b2 h
* |   b8756ee Merge branch 'b2' into pack
|\ \
| * | 8d82257 b2
| |/
| * e133de9 header
* | f2176e2 b11
* |   321356e Merge branch 'b12' into b11
|\ \
| * | c919951 b12
* | | 8b3055f b11
* | | 743fac2 b11
* | | a14be49 b11
|/ /
* | 3fad600 b11
* | c7d72d6 b1
|/
* baa687d (master) new common commit
|
* 9b4a890 initial
* 887d11b init

有时旧的分支引用可能不会被重新定位(即使树已经重新定位而没有它们)。在这种情况下,您可以手动恢复或更改某些引用。
现在是撤销先前的变基合并以便变基整个树的时候了。通过一些删除、重置/检出操作,得到了以下树形结构:
* f2176e2 (HEAD -> b11) b11
*   321356e Merge branch 'b12' into b11
|\
| * c919951 (b12) b12
* | 8b3055f b11
* | 743fac2 b11
* | a14be49 b11
|/
* 3fad600 b11
* c7d72d6 (b1) b1
| * d304281 (b3) b3
| * ed66668 b2 h
| | * 8d82257 (b2) b2
| |/
| * e133de9 header
|/
* baa687d (mirror/master, mirror/HEAD, master) new common commit
* 9b4a890 initial
* 887d11b init

这正是开发人员想要实现的目标:提交内容被3个分支共享。


1
可以使用 git rebase -i --rebase-merges 来简化此过程,以便您可以省略过程末尾的虚拟合并提交。在这里讨论:https://dev59.com/t2Uq5IYBdhLWcg3wbv5Z#69396585 - Waleed Khan

2
啊,我没有注意到你的命令是基于你第一个链接问题的被接受答案。无论如何,你明确地要求了你得到的结果:将E、F和G每个都变基到主分支上。
我认为你想要的是:
git rebase ... --onto master A feature

转换自

A - H (master)
|
\- B - C - D - E (feature)

为了

A - H (master)
    |
    \- B'- C'- D'- E'(feature)

(master是新根,A是旧根。使用feature^作为旧根意味着您只移植了feature的最后一次提交,正如您所看到的)

然后:

git rebase ... --onto D' D new
git rebase ... --onto D' D other

将新的和其他的从D分离出来,然后移植到D'上。请注意,在rebase feature之后,feature^表示的是D'而不是D


至于自动化这个过程,我可以给你展示一些差不多能用的东西,但麻烦的部分在于错误处理和恢复。

transplant_tree.sh

#!/bin/bash
trap "rm -f /tmp/$$.*" EXIT

function transplant() # <from> <to> <branch>
{
    OLD_TRUNK=$1
    NEW_TRUNK=$2
    BRANCH=$3

    # 1. get branch revisions
    REV_FILE="/tmp/$$.rev-list.$BRANCH"
    git rev-list $BRANCH ^$OLD_TRUNK > "$REV_FILE" || exit $?
    OLD_BRANCH_FORK=$(tail -1 "$REV_FILE")
    OLD_BRANCH_HEAD=$(head -1 "$REV_FILE")
    COMMON_ANCESTOR="${OLD_BRANCH_FORK}^"

    # 2. transplant this branch
    git rebase --onto $NEW_TRUNK $COMMON_ANCESTOR $BRANCH

    # 3. find other sub-branches:
    git branch --contains $OLD_BRANCH_FORK | while read sub;
    do
        # 4. figure out where the sub-branch diverges,
        # relative to the (old) branch head
        DISTANCE=$(git rev-list $OLD_BRANCH_HEAD ^$sub | wc -l)

        # 5. transplant sub-branch from old branch to new branch, attaching at
        # same number of commits before new HEAD
        transplant $OLD_BRANCH_HEAD ${BRANCH}~$DISTANCE  $sub
    done
}

transplant $1 $2 $3

针对您的需求,transplant_tree.sh master master feature应该可以工作,假设所有的rebase都成功了。它看起来应该是这样的:

  • OLD_TRUNK=NEW_TRUNK=master, BRANCH=feature
    1. 获取分支修订版本
      • OLD_BRANCH_FORK=B
      • OLD_BRANCH_HEAD=E
      • COMMON_ANCESTOR=B^ ==A
    2. 移植此分支
      • git rebase --onto master B^ feature
    3. 查找其他子分支
      • sub=new
        • DISTANCE=$(git rev-list E ^new | wc -l) == 1
        • 使用OLD_TRUNK=E、NEW_TRUNK=feature~1和BRANCH=new进行递归
      • sub=other
        • 等等

如果其中一个rebase失败了,它是否应该让您手动修复并以某种方式恢复?它是否应该能够回滚整个过程?


我想我之前并没有真正理解 ^ 语法,现在我明白了。你的解决方案可以行得通,但需要我去确定新的相关提交(DD'),这就是为什么我想要一个更自动化的过程。 - Jellby
顺便问一下,自动化版本对你有用吗? - Useless
公平地说,我没有尝试过你的解决方案。我现在正在使用类似于我在初始消息末尾编写的内容:合并所有受影响的分支,保留合并的变基,将分支头移回其原始(新)提交。 - Jellby

1

这是我第一次尝试使用 "plumbing" commands,欢迎提出改进意见。

你可以在这里使用的两个强大命令是 git for-each-refgit rev-list,以确定如何移动此树:

  • git for-each-ref refs/heads --contains B 给出了您想要移动的所有(本地)引用,即 featurenewother
  • git rev-list ^B^ feature new other 给出了您想要移动的所有提交。

现在,由于变基,我们实际上并不需要移动每个提交,只需要移动您的树中叶子节点或分支节点。因此,那些具有零个或2个(或更多)子提交的节点。

假设为了与变基保持一致,您提供以下参数:
git transplant-onto H A <list of branches>,那么您可以使用以下内容(并将其放置在路径下的git-transplant-onto中):
#!/usr/bin/env bash

function usage() {
    echo
    echo "Usage: $0 [-n] [-v] <onto> <from> <ref-list>"
    echo "    Transplants the tree from <from> to <onto>"
    echo "    i.e. performs git rebase --onto <onto> <from> <ref>, for each <ref> in <ref-list>"
    echo "    while maintaining the tree structure inside <ref-list>"
    exit $1
}

dry_run=0
verbose=0
while [[ "${1::1}" == "-" ]]; do
    case $1 in
        -n) dry_run=1 ;;
        -v) verbose=1 ;;
        *) echo Unrecognized option $1; usage -1 ;;
    esac
    shift
done

# verifications
if (( $# < 2 )) || ! onto=$(git rev-parse --verify $1) || ! from=$(git rev-parse --verify $2); then usage $#; fi
git diff-index --quiet HEAD || {echo "Please stash changes before transplanting"; exit 3}

# get refs to move
shift 2
if (( $# == 0 )); then
    refs=$(git for-each-ref --format "%(refname)" refs/heads --contains $from)
else
    refs=$(git show-ref --heads --tags $@ | cut -d' ' -f2)
    if (( $# != $(echo "$refs" | wc -l) )); then echo Some refs passed as arguments were wrong; exit 4; fi
fi

# confirm
echo "Following branches will be moved: "$refs
REPLY=invalid
while [[ ! $REPLY =~ ^[nNyY]?$ ]]; do read -p "OK? [Y/n]"; done
if [[ $REPLY =~ [nN] ]]; then exit 2; fi

# only work with non-redundant refs
independent=$(git merge-base --independent $refs)

if (( verbose )); then
    echo INFO:
    echo independent refs:;git --no-pager show -s --oneline --decorate $independent
    echo redundant refs:;git --no-pager show -s --oneline --decorate $(git show-ref $refs | grep -Fwvf <(echo "$independent") )
fi

# list all commits, keep those that have 0 or 2+ children
# so we rebase only forks or leaves in our tree
tree_nodes=$(git rev-list --topo-order --children ^$from $independent | sed -rn 's/^([0-9a-f]{40})(( [0-9a-f]{40}){2,})?$/\1/p')

# find out ancestry in this node list (taking advantage of topo-order)
declare -A parents
for f in $tree_nodes; do
    for p in ${tree_nodes#*$h} $from; do
        if git merge-base --is-ancestor $p $h ; then
            parents[$h]=$p
            break
        fi
    done
    if [[ ${parents[$h]:-unset} = unset ]]; then echo Failed at finding an ancestor for the following commit; git --no-pager show -s --oneline --decorate $h; exit 2; fi
done

# prepare to rebase, remember mappings
declare -A map
map[$from]=$onto

# IMPORTANT! this time go over in chronological order, so when rebasing a node its ancestor will be already moved
while read h; do
    old_base=${parents[$h]}
    new_base=${map[$old_base]}
    git rebase --preserve-merges --onto $new_base $old_base $h || {
        git rebase --abort
        git for-each-ref --format "%(refname:strip=2)" --contains $old_base refs/heads/ | \
            xargs echo ERROR: Failed a rebase in $old_base..$h, depending branches are:
        exit 1
    }
    map[$h]=$(git rev-parse HEAD)
done < <(echo "$tree_nodes" | tac)

# from here on, all went well, all branches were rebased.
# update refs if no dry-run, otherwise show how

ref_dests=
for ref in $refs; do
    # find current and future hash for each ref we wanted to move
    # all independent tags are in map, maybe by chance some redundant ones as well
    orig=$(git show-ref --heads --tags -s $ref)
    dest=${map[$orig]:-unset}

    # otherwise look for a child in the independents, use map[child]~distance as target
    if [[ $dest = unset ]]; then
        for child in $independent; do
            if git merge-base --is-ancestor $ref $child ; then
                dest=$(git rev-parse ${map[$child]}~$(git rev-list $ref..$child | wc -l) )
                break
            fi
        done
    fi

    # finally update ref
    ref_dests+=" $dest"
    if (( dry_run )); then
        echo git update-ref $ref $dest
    else
        git update-ref $ref $dest
    fi
done

if (( dry_run )); then
    echo
    echo If you apply the update-refs listed above, the tree will be:
    git show-branch $onto $ref_dests
else
    echo The tree now is:
    git show-branch $onto $refs
fi

另一种方法是按照你可以转置的顺序(例如git rev-list --topo-order --reverse --parents)获取所有单个提交及其父项,然后对每个单个提交使用git am

0

我认为你不应该真的想要自动化处理这种情况。即使是简单的分支重定位通常也需要仔细注意。如果在旧分支和/或超过几个提交上进行,成功的机会非常小。而且你似乎有一个完整的代码库。

但是,如果情况并不是那么模糊,重定位大多数情况下都有效,只需发出交互式重定位命令并手动剪切待办事项即可只需几秒钟。

从性质上看,我也没有看到完全自动化的解决方案。

但是为了建设性,这是我尝试解决问题的方法:

  • 首先创建一个可以分析源代码历史记录并给出树形分支的程序。对于上面的示例,它将在A上给出B..D;在D上给出E..E;在D上给出F..G。
  • 将A'标记为H(来自外部输入)
  • 将基于A的最低块应用于A':将B..D cherry-pick(或重新定位)到A';
  • 将新的顶部标记为D'
  • 应用已经完成映射的其余块

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接