如何在Git中进行重命名的分段操作,而无需进行后续编辑?

28

我有一个文件,我已经重命名并进行了编辑。我希望告诉Git暂存重命名,但不包括内容修改。也就是说,我想暂存旧文件名的删除和旧文件内容与新文件名的添加。

所以我现在有这样的情况:

Changes not staged for commit:

        deleted:    old-name.txt

Untracked files:

        new-name.txt

但是希望得到以下两种结果之一:

Changes to be committed:

        new file:   new-name.txt
        deleted:    old-name.txt

Changes not staged for commit:

        modified:   new-name.txt

或者这样:

Changes to be committed:

        renamed:    old-name.txt -> new-name.txt

Changes not staged for commit:

        modified:   new-name.txt

(相似度度量应为100%)。

我想不出一种简单直接的方法来做到这一点。

是否有语法可以获取特定文件的特定版本的内容,并将其添加到指定路径下的git暂存区中?

使用git rm删除部分是没问题的:

$ git rm old-name.txt

我在修改文件名时遇到了添加部分的问题。(我可以保存新内容,检出一个新副本(旧内容),在shell中使用mv命令,然后使用git add命令,最后恢复新内容,但这似乎是一种非常麻烦的方式!)

谢谢!


1
如果原始文件已被删除或目标路径已存在,则仅使用 git mv 无效。您需要执行保存/恢复过程,就像您所概述的那样... 在此过程中,您可以使用 git mv 而不是 mv + git add。由于在更改时 Git 没有跟踪 new-name.txt,因此它无法帮助分离这些更改。 - hineroptera
3个回答

45

Git不真正进行重命名,所有的重命名都是“事后”计算的:git比较一个提交和另一个提交,并且在比较时决定是否存在重命名。这意味着,无论Git是否认为某些内容是“重命名”,都会动态变化。当你询问 git(通过 git show git log -p git diff HEAD^ HEAD )“最后一次提交发生了什么”,它会运行前一次提交( HEAD ^ HEAD~1或前一次提交的实际原始 SHA-1—任何一个都可以用于标识它)和当前提交( HEAD )之间的差异。在制作差异时,它可能会发现以前有个 old.txt ,现在没有了;而且没有 new.txt ,但现在有了。这些文件名—曾经存在但现在不存在的文件名,以及现在存在但以前不存在的文件名—被放进标记为“重命名候选项”的堆中。然后,对于堆中的每个名称,git会比较“旧内容”和“新内容”。由于git将内容缩减为SHA-1,因此对于“精确匹配”的比较非常容易;如果精确匹配失败,则git会切换到可选的“内容至少相似”的diff来检查重命名。使用 git diff ,此可选步骤由 -M 标志控制。对于其他命令,它要么由您的 git config 值设置,要么被硬编码到命令中。

现在回到暂存区和 git status :git在索引/暂存区中存储的基本上是“下一次提交的原型”。当您 git add 某个东西时,git会在该点存储文件内容,同时计算SHA-1,然后将SHA-1存储在索引中。当您 git rm 某个东西时,git会在索引中存储一个注释,说“这个路径名在下一次提交中被故意删除”。因此, git status 命令只是进行了差异比较,确切地说,是两个差异比较: HEAD 与索引的比较,用于将被提交的内容;以及索引与工作树的比较,用于可能(但尚未)被提交的内容。

在第一个差异中,Git使用与以往相同的机制来检测重命名。如果HEAD提交中存在路径不存在于索引中,并且索引中存在一个新的路径,不在HEAD提交中,则它是重命名检测的候选项。 git status命令将重命名检测硬编码为“on”(文件计数限制为200;对于只有一个重命名检测候选项,此限制已经足够)。


这对你的情况意味着什么呢?你重命名了一个文件(没有使用git mv,但这并不重要,因为git status会在git status时间找到或未找到重命名),现在有一个更新的、不同版本的新文件。

如果你用git add添加新版本,那么新版本就会进入仓库,它的SHA-1值将在索引中,并且当git status做差异比较时,它会比较新旧版本。如果它们至少“50%相似”(git status的硬编码值),Git会告诉你文件已被重命名。

当然,用git add添加修改后的内容并不完全符合你的要求:你想做一个中间提交,其中文件仅被重命名,即一个具有新名称但旧内容的树的提交。

你不必这样做,因为以上所有动态重命名检测都可以处理。如果你想这么做(无论出于任何原因)...好吧,Git并不是特别容易实现。

最简单的方法就是按照你建议的方式:将修改后的内容移到一边,使用git checkout -- old-name.txt,然后git mv old-name.txt new-name.txt,然后提交。git mv将重命名索引/暂存区中的文件,并重命名工作树版本。

如果git mv有一个类似于git rm的--cached选项,你可以只用git mv --cached old-name.txt new-name.txt,然后git commit。第一步将在索引中重命名文件,而不影响工作树。但事实并非如此:它坚持要覆盖工作树版本,并坚持要求旧名称必须从工作树中开始存在。

在不触及工作树的情况下执行此操作的单个步骤方法是使用git update-index --index-info,但这也有点混乱(我稍后会展示它)。幸运的是,我们还有最后一件事情可以做。我已经设置了与你相同的情况,通过将旧名称重命名为新名称并修改文件:

$ git status
On branch master
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    deleted:    old-name.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)

    new-name.txt
现在我们要做的是首先将文件手动放回旧名称下,然后使用git mv再次切换到新名称
$ mv new-name.txt old-name.txt
$ git mv old-name.txt new-name.txt

这次git mv会更新索引中的名称,但将原始内容保留为索引SHA-1值,并将工作树版本(新内容)移动到工作树中:

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    renamed:    old-name.txt -> new-name.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   new-name.txt

现在只需使用git commit命令进行提交,以便将重命名的文件提交到原地,但不包括新的内容。

(请注意,这取决于没有用旧名称创建的新文件!)


那么使用git update-index呢?首先,让我们将事情恢复到“工作树中已更改,索引与HEAD提交匹配”的状态:

$ git reset --mixed HEAD  # set index=HEAD, leave work-tree alone

现在让我们看看old-name.txt索引中的内容:

$ git ls-files --stage -- old-name.txt
100644 2b27f2df63a3419da26984b5f7bafa29bdf5b3e3 0   old-name.txt

因此,我们需要使用git update-index --index-info来清除old-name.txt的条目,但要创建一个与之前完全相同的new-name.txt条目:

$ (git ls-files --stage -- old-name.txt;
   git ls-files --stage -- old-name.txt) |
  sed -e \
'1s/^[0-9]* [0-9a-f]*/000000 0000000000000000000000000000000000000000/' \
      -e '2s/old-name.txt$/new-name.txt/' | 
  git update-index --index-info

(注意:我将上面的内容分开发布,但当我输入时它是一行;在sh/bash中,只需添加反斜杠以继续“sed”命令,就可以按照这种方式工作。)

还有其他方法可以做到这一点,但简单地提取索引条目两次并将第一个修改为删除,第二个修改为新名称似乎是最容易的,在这里使用了sed命令。第一个替换更改文件模式(100644,但任何模式都将变成全零)和SHA-1(匹配任何SHA-1,替换为git的特殊全零SHA-1),第二个保持模式和SHA-1不变,同时替换名称。

当update-index完成后,索引记录了旧路径的删除和新路径的添加(具有与旧路径相同的模式和SHA-1)。

请注意,如果索引中存在未合并条目的old-name.txt,则可能会严重失败,因为该文件可能具有其他阶段(1至3)。


2
我最喜欢你列出的选项是将其重命名回旧名称,然后使用git mv - Brian J
1
另外值得注意的是,您可以使用 git stash 命令返回到原始状态,然后执行重命名操作的 git mv 命令。提交后,您可以使用 git stash --pop 命令(可能会出现合并冲突),回到之前的状态。 - Brian J
1
@BrianJ:是的,这(mv,然后git mv)绝对是最容易的。 如果您使用旧名称创建了新文件,则可能会更加困难,因为在这种情况下没有“免费插槽”可以移动15个拼图块 :) - torek
@BrianJ:通常情况下不会,我认为即使使用了--find-copies-harder现在也不会,但在计算成本高的模式下它可能会,也许有人将来会让git这样做。 "git diff"明天变得更聪明这一事实是Linus今天采用这种方式做所有事情的论据之一。 - torek
1
当指定了-B命令行选项以启用重写文件的打破(意味着经过大量修改的文件可以成为重命名检测的候选文件)时,git diff应该能够处理这种情况,显示从A重命名为B和一个新文件在A。然而,git status不直接启用此特定选项。 - Edward Thomson
显示剩余2条评论

27

@torek给出了非常清晰和详实的答案。那里有很多非常有用的细节,值得仔细阅读。

但是,为了那些匆忙的人,这个非常简单的解决方案的关键是:

现在我们所做的是,首先手动将文件放回到它的旧名称下,然后使用git mv再次切换到新名称:

$ mv new-name.txt old-name.txt
$ git mv old-name.txt new-name.txt
(我缺少的只是 "mv" 命令,这样才能使用 "git mv"。)
(如果您觉得这篇文章有帮助,请给 @torek 的回答点赞。)

0

我使用Git GUI Sourcetree。它具有对文件的分段暂存和取消暂存“块”或突出显示单个行并将其暂存或取消暂存的功能。

我能够将删除的文件和新文件暂存,以使GUI将其检测为“重命名”。然后我遍历了所有更改的行并将它们取消暂存。所以现在我已经将重命名暂存,但没有文件更改。

我知道不是每个人都使用这个UI。但据我所知,它基于此处描述的“补丁”的交互式暂存:https://git-scm.com/book/en/v2/Git-Tools-Interactive-Staging。因此,我会研究一下。暂存所有内容,然后取消暂存补丁。


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