Git复制文件,与`git mv`相反

48

我知道git通过比较文件内容来工作。我想要复制一些文件。为了绝对防止git混淆,是否有一些可以将文件复制到另一个目录(不是mv,而是cp)并将这些文件暂存的git命令?


3
git mv是一个存在的命令,但是git cp不是一个命令。 - Alexander Mills
1
是的...这里没有危险。git mv只是一个快捷方式(用于mvgit removegit rm)...它不是为了避免混淆。 - JDB
5
对于这种用例,使用git cp会很好:https://dev59.com/Z2Qn5IYBdhLWcg3wTloS - zzxyz
有另一种方法使用解决方案。我已经测试过,效果很好。 - BUZA László
显示剩余3条评论
2个回答

61

简短的答案是“不行”。但是需要了解更多,这需要一些背景知识。 (正如JDB在评论中建议的那样,我将提到为什么git mv存在作为一种便利方式。)

稍微长一点:您说得对,Git会区分文件,但是您可能错误地认为Git何时进行这些文件差异比较。

Git的内部存储模型建议每个提交是该提交中所有文件的独立快照。进入新提交的每个文件的版本,即该路径下的快照中的数据,是在运行git commit时在索引下的任何内容。1

实际实现,到第一级别,是将每个拍摄文件以压缩形式捕获为Git数据库中的blob对象。 blob对象与该文件的所有先前和后续版本相当独立,除了一个特殊情况:如果创建新提交,在其中没有更改数据,您将重用旧的blob 。因此,当您连续进行两个提交,每个提交都包含100个文件,并且只更改了一个文件时,第二个提交将重用99个先前的blob,并且只需要将一个实际文件快照到新的blob中。2

因此,Git会区分文件的事实根本不涉及进行提交。没有提交依赖于先前的提交,除了存储先前提交的哈希ID(也许重新使用完全匹配的blob,但这是它们完全匹配的副作用,而不是在运行git commit 时进行的高级计算)。

现在,所有这些独立的blob对象最终会占用大量空间。 此时,Git可以将对象“打包”成.pack 文件。它将比较每个对象与某些选定的其他对象-它们可能早于或晚于历史记录,具有相同的文件名或不同的文件名,并且理论上Git甚至可以对提交对象和blob对象进行压缩或反之亦然(但在实践中并不如此)-并尝试找到一种使用更少的磁盘空间来表示多个blob的方法。但结果仍然是逻辑上的一系列独立对象,使用其哈希ID完全保持原样检索。因此,即使此时使用的磁盘空间量下降(我们希望如此!),所有对象仍然与以前完全相同。

因此,Git何时比较文件?答案是:只有当您要求时。“询问时间”是在直接运行git diff时:

git diff commit1 commit2

或者间接地:

git show commit  # roughly, `git diff commit^@ commmit`
git log -p       # runs `git show commit`, more or less, on each commit
这方面有很多微妙之处,在特定情况下,当运行合并提交时,git show将生成Git称为“combined diffs”的内容,而git log -p通常会直接跳过合并提交的差异,但是在这些以及其他一些重要情况下,都需要使用git diff。 只有在Git运行git diff时,你才能(有时)要求它查找或不查找副本。-C标志,也拼写为--find-copies=<number>,要求Git查找副本。--find-copies-harder标志(Git文档称其为“计算代价高昂”)比普通的-C标志更努力地寻找副本。-B(打破不适当的配对)选项会影响-C。-M即--find-renames=<number>选项也会影响-C。可以告诉git merge命令调整其重命名检测级别,但至少目前不能告诉它查找副本,也不能打破不适当的配对。 (一个命令git blame执行了略有不同的副本查找,上述内容并不完全适用于它。)
1如果运行git commit --include <paths>或git commit --only <paths>或git commit <paths>或git commit -a,请将它们视为在运行git commit之前修改索引。在特定情况下,对于--only,Git使用临时索引,这有点复杂,但它仍然从一个索引提交-它只是使用特殊的临时索引而不是常规的索引。为了创建临时索引,Git复制所有文件从HEAD提交中,然后用你列出的--only文件覆盖它们。对于其他情况,Git只是将工作树文件复制到常规索引中,然后像往常一样从索引进行提交。 2实际上,在git add期间进行快照,将blob存储到存储库中。这秘密使git commit变得更快,因为通常你不会注意到在启动git commit之前运行git add需要额外的时间。

为什么存在git mv

git mv old new的大致操作是:
mv old new
git add new
git add old

第一步很明显:我们需要重命名文件的工作树版本。第二步类似:我们需要将索引版本的文件放到位。然而,第三步有点奇怪:为什么我们要“添加”一个刚刚删除的文件?好吧,git add并不总是添加一个文件:实际上,在这种情况下它检测到文件已经在索引中了,但现在不在了。

我们也可以将第三步写成:

git rm --cached old

我们真正要做的就是从索引中删除旧名称。

但这里有一个问题,这也是我说“非常粗略”的原因。索引中存储着每个文件的副本,而这些文件将在下次运行git commit时提交。这个副本可能与工作树中的副本不匹配。实际上,它甚至可能不匹配HEAD中的副本(如果HEAD中存在)。

例如,执行以下操作后:

echo I am a foo > foo
git add foo

文件foo存在于工作树和索引中。工作树内容和索引内容相匹配。但现在让我们更改工作树版本:

echo I am a bar > foo

现在索引和工作树不同。假设我们想要将基础文件从foo移动到bar,但出于某种奇怪的原因3,我们希望保持索引内容不变。如果我们运行:

mv foo bar
git add bar

我们将在新的索引文件中得到I am a bar。如果我们从索引中移除旧版本的foo,我们会完全失去I am a foo版本。

因此,git mv foo bar并不是真正的移动加两次或者移动-添加-删除。相反,它重命名工作树文件和索引副本。如果原始文件的索引副本与工作树文件不同,则重命名的索引副本仍然与重命名的工作树副本不同。

没有像git mv这样的前端命令很难做到这一点。4当然,如果您计划将所有东西都添加到git add中,您首先就不需要所有这些东西。值得注意的是,如果存在git cp,在创建索引副本时它可能应该复制索引版本而不是工作树版本。所以,git cp确实应该存在。还应该有一个git mv --after选项,就像Mercurial的hg mv --after一样。两者都应该存在,但目前并不存在。(在我看来,比起这两个,对于纯粹的git mv的需求更少。)


3对于这个例子来说,有点傻和无意义。但是,如果您使用git add -p仔细准备中间提交的补丁,然后决定除了补丁外,您想重命名文件,那么能够在不破坏精心拼凑的中间版本的情况下完成此操作肯定是很方便的。

4这并非不可能:通过git ls-index --stage可以获得索引的所需信息,因为它现在就是这样,而git update-index允许您对索引进行任意更改。您可以将这两者结合起来,并在更好的语言中进行一些复杂的Shell脚本或编程,从而构建实现git mv --aftergit cp的东西。


2
我认为你可能想要研究一下为什么不需要 git cp,因为 git mv 只是 mvgit addgit rm 的简写。 - JDB
1
@JDB:已完成,尽管我认为“git cp”也是有意义的(请参见新部分)。 - torek
1
在git extras中有一个git cp命令。我对git的工作原理了解不够,无法对它是否执行合理操作做出任何陈述。 - demented hedgehog
@dementedhedgehog:假设您指的是https://github.com/tj/git-extras/blob/master/bin/git-cp(通过谷歌搜索“git-extras”找到),它不会复制索引版本(我认为这将是正确的操作),并且它在最后运行`git commit`(我认为这是不合适的),所以这不是我会提供的。但口味不同。 - torek
嗯,有趣。 - demented hedgehog
@torek 你需要看一下我即将提供的答案...它是动态诗歌...或者说,至少是分支的。 - eftshift0

2

这虽然有点“hackish”,但可以通过在单独的分支上重命名来欺骗git本身,强制git在合并时保留两个文件来解决问题。

git checkout -b rename-branch
git mv a.txt b.txt
git commit -m "Renaming file"
# if you did a git blame of b.txt, it would _follow_ a.txt history, right?
git checkout main
git merge --no-ff --no-commit rename-branch
git checkout HEAD -- a.txt # get the file back
git commit -m "Not really renaming file"

如果直接复制,你会得到这个:

$ git log --graph --oneline --name-status
* 70f03aa (HEAD -> master) COpying file straight
| A     new_file.txt
* efc04f3 (first) First commit for file
  A     hello_world.txt
$ git blame -s new_file.txt
70f03aab 1) I am here
70f03aab 2) 
70f03aab 3) Yes I am
$ git blame -s hello_world.txt
^efc04f3 1) I am here
^efc04f3 2) 
^efc04f3 3) Yes I am

使用侧边栏上的“重命名”并获取文件后,您会得到:

$ git log --oneline --graph master2 --name-status
*   30b76ab (HEAD, master2) Not really renaming
|\  
| * 652921f Renaming file
|/  
|   R100        hello_world.txt new_file.txt
* efc04f3 (first) First commit for file
  A     hello_world.txt
$ git blame -s new_file.txt
^efc04f3 hello_world.txt 1) I am here
^efc04f3 hello_world.txt 2) 
^efc04f3 hello_world.txt 3) Yes I am
$ git blame -s hello_world.txt
^efc04f3 1) I am here
^efc04f3 2) 
^efc04f3 3) Yes I am

这样做的原因是,如果您想查看原始文件的历史记录,Git 将无需任何问题地执行它... 如果您想在副本上执行此操作,则 Git 将跟随重命名所在的单独分支,然后能够通过副本跳转到原始文件,只是因为它是在分支上完成的。


这个解决方案还有一个更长的版本,需要使用3个分支。但是这个版本只需要更少的步骤就能完成。我唯一的区别是我已经在'main'分支上做了我的更改,所以我必须先使用git checkout -b rename_branch命令,然后在main分支上使用git reset --hard HEAD~命令。 - undefined

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