我意识到 git 通过区分文件内容来工作。我有一些文件要复制。为了绝对防止 git 混淆,是否有一些 git 命令可用于将文件复制到不同的目录(不是 mv,而是 cp),并暂存文件?
简短的回答是"否"。 但还有更多需要了解的内容;它只需要一些背景。 (正如JDB在评论中建议的那样,我将提到为什么git mv
是为了方便而存在。
稍微长一点:你是对的,Git 会比较文件,但你可能错了 Git何时做这些文件差异。
Git 的内部存储模型建议每个提交都是该提交中所有文件的独立快照。 进入新提交的每个文件的版本,即该路径快照中的数据,是运行git commit
时该路径下的索引中的任何内容。1
到第一级的实际实现是,每个快照文件都以压缩形式捕获为 Git 数据库中的blob 对象。 Blob 对象完全独立于该文件的每个以前和后续版本,但一种特殊情况除外:如果进行新提交且未更改任何数据,则将重用旧 Blob。 因此,当你连续进行两次提交时,每个提交包含 100 个文件,并且只更改了一个文件,第二次提交将重用 99 个以前的 Blob,并且只需要将一个实际文件快照到新 Blob 中。阿拉伯数字
因此,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 所谓的组合差异,而git log -p
通常只是跳过合并提交的差异——但这些以及其他一些重要情况是 Git 运行时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实际上,实际的快照(将 blob 存储到存储库中)发生在git add
期间。 这秘密地使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
这样的前端命令,很难做到这一点。四当然,如果你打算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 --after
和git cp
的东西。
这很黑客,但可以通过在单独的分支上重命名并强制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 将遵循重命名所在的单独分支,然后它将能够跳转到副本之后的原始文件,只是因为它是在该分支上完成的。