合并但不改变工作目录

19

我有以下情形:

* ab82147 (HEAD, topic) changes
* 8993636 changes
* 82f4426 changes
* 18be5a3 (master) first
我想将(非快进)topic合并到master。 这需要我执行以下操作:
  • git checkout master
  • git merge --no-ff topic
但是,检出master,然后将topic合并到其中会导致git更改我的工作目录(尽管最终结果与之前检出master之前相同),而这个问题源于我们项目的规模,构建它需要大约30分钟(使用IncrediBuild),尽管没有真正变化,但这简直难以承受。
所以我想得到以下内容:
*   9075cf4 (HEAD, master) Merge branch 'topic'
|\  
| * ab82147 (topic) changes
| * 8993636 changes
| * 82f4426 changes
|/  
* 18be5a3 first

不真正接触工作目录(或者至少通过某种方式欺骗git)。


似乎你的构建链出现了问题。你需要在哪个步骤重新构建? - cmcginty
1
@Casey:不是的,构建链没问题。考虑一个在主分支和话题分支之间被修改过的文件。当你切换到主分支时,它会变成主分支上的版本,并更新时间戳。然后你合并话题分支,将其改回原始版本,但它仍然被触及,因此必须重新构建它的产品! - Cascabel
2
要进行无需检出的快速向前合并,请参见(1): Git checkout-and-merge without touching working tree,以及 (2) Update/pull a local Git branch without checking it out? - user456814
可能相关:不检出合并分支 - user456814
6个回答

8
有趣!我不认为有内置的方法可以做到这一点,但您应该能够使用管道来模拟它:
#!/bin/bash

branch=master
# or take an argument:
# if [ $@ eq 1 ];
#      branch="$1";
# fi

# make sure the branch exists
if ! git rev-parse --verify --quiet --heads "$branch" > /dev/null; then
     echo "error: branch $branch does not exist"
     exit 1
fi

# make sure this could be a fast-forward   
if [ "$(git merge-base HEAD $branch)" == "$(git rev-parse $branch)" ]; then
    # find the branch name associated with HEAD
    currentbranch=$(git symbolic-ref HEAD | sed 's@.*/@@')
    # make the commit
    newcommit=$(echo "Merge branch '$currentbranch'" | git commit-tree $(git log -n 1 --pretty=%T HEAD) -p $branch -p HEAD)
    # move the branch to point to the new commit
    git update-ref -m "merge $currentbranch: Merge made by simulated no-ff" "refs/heads/$branch" $newcommit
else
    echo "error: merging $currentbranch into $branch would not be a fast-forward"
    exit 1
fi

有趣的部分是 newcommit= line; 它使用 commit-tree 直接创建合并提交。第一个参数是要使用的树;那就是 HEAD,即您想保留其内容的分支。提交消息在 stdin 上提供,其余参数命名新提交应具有的父项。提交的 SHA1 将打印到 stdout,因此假设提交成功,您可以捕获它,然后合并该提交(这将是快进)。如果您过于执着,可以确保 commit-tree 成功-但这应该几乎可以保证。

限制:

  • 这仅适用于本来可以进行快进合并的合并。显然,在这种情况下,您实际上必须检出和合并(可能在克隆中,以保存构建系统)。
  • reflog 消息不同。我故意这样做,因为当您使用 --no-ff 时,git 实际上会强制自己使用默认(递归)策略,但在 reflog 中编写它将是一个谎言。
  • 如果您处于分离的 HEAD 模式下,则情况将变得糟糕。那必须特别处理。

是的,我在玩具存储库上测试了这个,它似乎正常工作!(虽然我没有尝试过努力打破它。)


我对这个神奇的git印象深刻!我刚刚在我之前提到的repo上尝试了一下,结果相当奇怪;-) 这是之后图形的样子:http://paste.lisp.org/+2FCI - Idan K
@Idan K:看起来正是你想要的,不过你还需要检出主分支,对吧? - Cascabel
@Jefromi:仔细看一下应该属于主题的 *。它们在左边的线上而不是右边的线上。正常合并会产生这样的图形:http://paste.lisp.org/+2FCS - Idan K
@Jefromi:我肯定我试过了,该死;-) 我知道这只是一些愚蠢的事情。非常感谢,我会在下一次合并时尝试这个方法。 - Idan K
@Jefromi:我在我的git脚本中包含了一个(稍作修改的)版本,网址为https://github.com/johnbartholomew/gitvoodoo。我想以GPLv3发布这些脚本 - 你能告诉我这是否可行吗?(代码来源在README和脚本顶部附近的注释中有说明)。感谢您提供的有用扩展! - John Bartholomew
显示剩余7条评论

3
我能想到的最简单的方法是将 git clone 到一个单独的工作副本,然后在那里进行合并,然后再 git pull 回来。这个拉取操作会是快进,并且只会影响实际发生变化的文件。
当然,对于如此大的项目来说,制作临时克隆并不理想,需要大量额外的硬盘空间。通过保留您的合并副本(长期保留,只要您不需要磁盘空间),可以尽量减少额外克隆的时间成本。
免责声明:我没有验证过这是否有效。但我相信它应该有效(git 不会版本化文件时间戳)。

1
在本地机器上进行克隆可以使用硬链接甚至共享对象目录。这将节省大量空间。 - siride
它是否硬链接实际工作副本文件,还是只是存储库对象?此外,在Windows上是否也适用?(原问题提到了IncrediBuild,所以我假设是在Windows上...可能是msysGit) - John Bartholomew
3
它肯定不会硬链接工作树中的文件——如果整个东西都是一样的话,克隆的意义在哪里呢? - Cascabel
我对Windows不是很了解,但维基百科提到在Windows中可以创建硬链接,并且NTFS可以做符号链接 - 因此git-new-workdir脚本也可能是一个选项。http://git.kernel.org/?p=git/git.git;a=blob;f=contrib/workdir/git-new-workdir;h=993cacf324b8595e5be583ff372b25353c7af95c;hb=HEAD - Cascabel
2
实际上,这就是我目前所做的事情,磁盘空间的损失并不是一个真正的问题(与构建时间相比)。但我正在寻找一种更加“优雅”的解决方案,可以在当前代码库中原地进行操作。 - Idan K

2

完全可以在不使用git checkout、不影响提交历史或克隆的情况下进行任何合并,即使是非快进式合并也可以。秘诀在于添加第二个“工作树”,这样你就可以在同一仓库中拥有主要和次要的两个检出。

cd local_repo
git worktree add _master_wt master
cd _master_wt
git pull origin master:master
git merge --no-ff -m "merging workbranch" my_work_branch
cd ..
git worktree remove _master_wt

您现在已经将本地工作分支合并到本地 master 分支中,而不需要切换您的检出。


保留第二个工作树而不是将其删除是否会有任何缺点?这样下次您想合并而不是检出主分支时,它就会存在。对于大型存储库,似乎不每次重新创建新的工作树可能更快。 - DecimalTurn
我看到了三件事:(1)包含两个工作树的文件夹大小会更大。(2)您将无法在第一个工作树中检出主分支,但如果您只使用主分支来合并功能,则这不是真正的问题。(3)您需要将_master_wt目录添加到.gitignore中,以避免在原始工作树中有未决更改。我还错过了其他什么吗? - DecimalTurn

0

或者,您可以通过保存和恢复文件时间戳来直接修复症状。这有点丑陋,但编写它很有趣。

Python 时间戳保存/恢复脚本

#!/usr/bin/env python

from optparse import OptionParser
import os
import subprocess
import cPickle as pickle

try:
    check_output = subprocess.check_output
except AttributeError:
    # check_output was added in Python 2.7, so it's not always available
    def check_output(*args, **kwargs):
        kwargs['stdout'] = subprocess.PIPE
        proc = subprocess.Popen(*args, **kwargs)
        output = proc.stdout.read()
        retcode = proc.wait()
        if retcode != 0:
            cmd = kwargs.get('args')
            if cmd is None:
                cmd = args[0]
            err = subprocess.CalledProcessError(retcode, cmd)
            err.output = output
            raise err
        else:
            return output

def git_cmd(*args):
    return check_output(['git'] + list(args), stderr=subprocess.STDOUT)

def walk_git_tree(rev):
    """ Generates (sha1,path) pairs for all blobs (files) listed by git ls-tree. """
    tree = git_cmd('ls-tree', '-r', '-z', rev).rstrip('\0')
    for entry in tree.split('\0'):
        print entry
        mode, type, sha1, path = entry.split()
        if type == 'blob':
            yield (sha1, path)
        else:
            print 'WARNING: Tree contains a non-blob.'

def collect_timestamps(rev):
    timestamps = {}
    for sha1, path in walk_git_tree(rev):
        s = os.lstat(path)
        timestamps[path] = (sha1, s.st_mtime, s.st_atime)
        print sha1, s.st_mtime, s.st_atime, path
    return timestamps

def restore_timestamps(timestamps):
    for path, v in timestamps.items():
        if os.path.isfile(path):
            sha1, mtime, atime = v
            new_sha1 = git_cmd('hash-object', '--', path).strip()
            if sha1 == new_sha1:
                print 'Restoring', path
                os.utime(path, (atime, mtime))
            else:
                print path, 'has changed (not restoring)'
        elif os.path.exists(path):
            print 'WARNING: File is no longer a file...'

def main():
    oparse = OptionParser()
    oparse.add_option('--save',
        action='store_const', const='save', dest='action',
        help='Save the timestamps of all git tracked files')
    oparse.add_option('--restore',
        action='store_const', const='restore', dest='action',
        help='Restore the timestamps of git tracked files whose sha1 hashes have not changed')
    oparse.add_option('--db',
        action='store', dest='database',
        help='Specify the path to the data file to restore/save from/to')

    opts, args = oparse.parse_args()
    if opts.action is None:
        oparse.error('an action (--save or --restore) must be specified')

    if opts.database is None:
        repo = git_cmd('rev-parse', '--git-dir').strip()
        dbpath = os.path.join(repo, 'TIMESTAMPS')
        print 'Using default database:', dbpath
    else:
        dbpath = opts.database

    rev = git_cmd('rev-parse', 'HEAD').strip()
    print 'Working against rev', rev

    if opts.action == 'save':
        timestamps = collect_timestamps(rev)
        data = (rev, timestamps)
        pickle.dump(data, open(dbpath, 'wb'))
    elif opts.action == 'restore':
        rev, timestamps = pickle.load(open(dbpath, 'rb'))
        restore_timestamps(timestamps)

if __name__ == '__main__':
    main()

Bash测试脚本

#!/bin/bash

if [ -d working ]; then
    echo "Cowardly refusing to mangle an existing 'working' dir."
    exit 1
fi

mkdir working
cd working

# create the repository/working copy
git init

# add a couple of files
echo "File added in master:r1." > file-1
echo "File added in master:r1." > file-2
mkdir dir
echo "File added in master:r1." > dir/file-3
git add file-1 file-2 dir/file-3
git commit -m "r1: add-1, add-2, add-3"
git tag r1
# sleep to ensure new or changed files won't have the same timestamp
echo "Listing at r1"
ls --full-time
sleep 5

# make a change
echo "File changed in master:r2." > file-2
echo "File changed in master:r2." > dir/file-3
echo "File added in master:r2." > file-4
git add file-2 dir/file-3 file-4
git commit -m "r2: change-2, change-3, add-4"
git tag r2
# sleep to ensure new or changed files won't have the same timestamp
echo "Listing at r2"
ls --full-time
sleep 5

# create a topic branch from r1 and make some changes
git checkout -b topic r1
echo "File changed in topic:r3." > file-2
echo "File changed in topic:r3." > dir/file-3
echo "File added in topic:r3." > file-5
git add file-2 dir/file-3 file-5
git commit -m "r3: change-2, change-3, add-5"
git tag r3
# sleep to ensure new or changed files won't have the same timestamp
echo "Listing at r3"
ls --full-time
sleep 5

echo "Saving timestamps"
../save-timestamps.py --save

echo "Checking out master and merging"
# merge branch 'topic'
git checkout master
git merge topic
echo "File changed in topic:r3." > file-2 # restore file-2
echo "File merged in master:r4." > dir/file-3
git add file-2 dir/file-3
git commit -m "r4: Merge branch 'topic'"
git tag r4
echo "Listing at r4"
ls --full-time

echo "Restoring timestamps"
../save-timestamps.py --restore
ls --full-time

我会把清理Python脚本以删除多余输出和添加更好的错误检查留给读者作为练习。

0

简单,按照以下3个步骤操作:

  1. 将主分支合并到topic分支:git merge origin/master
  2. 修改master的head指针:git update-ref refs/heads/master refs/heads/topic

现在你可以回到merge之前的topic提交: 3. git reset HEAD~


-2

这里有一种“作弊”的方法。

  1. git stash
  2. git tag tmptag
  3. git merge --no-ff topic
  4. git checkout tmptag (-b tha_brunch)?
  5. git stash pop
  6. git tag -D tmptag

这将绝对改变工作树之间的关系。 - Mike Shiyan

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