在Git中是否可以移动/重命名文件并保留其历史记录?

843

我想要重命名/移动 Git 中的项目子树,将其从...

/project/xyz
为了更好地处理这个请求,我需要更具体的上下文信息。请提供完整的 Stack Overflow 问题或回答以便我进行翻译。
/components/xyz

如果我使用简单的git mv project components,那么关于xyz项目的所有提交历史记录都会丢失。是否有一种方法可以移动它并保留历史记录?


15
git mv 的作用是将文件重命名或移动到一个新位置,并在 Git 中提交该更改。使用 git mv 命令可以自动完成重命名或移动操作,同时更新 Git 索引和工作树中的文件位置,而不需要手动执行多个命令来完成这些操作。 - cregox
5
我想指出,我刚刚测试了通过文件系统移动文件,在提交(通过Intellij)后,当查看历史记录时(同样在Intellij中),我可以看到整个历史记录(包括在不同位置时的历史记录)。我假设Intellij没有执行任何特别的操作来完成这个功能,所以至少我们知道可以追踪历史记录。 - B T
有关Git在检测目录重命名时遵循的规则,请参见我的下面的答案 - VonC
我在这里写了一个答案,希望它能够解决问题。https://dev59.com/1Ggv5IYBdhLWcg3wI9Sg#55339543 - Mahmut EFE
Git Subtrees本质上有“虚假”的历史。当您使用git-subtree拆分存储库时,Git会赋予生成的子树一个比与其分离的项目不同的虚构历史。我认为Git试图确定涉及子树中任何文件的所有提交,并使用它们来拼接历史记录。此外,每次重新组合和重新拆分子树时,这些历史记录都会被重写。然而,子模块各自具有与父项目分开的独立历史记录。 - Nate T
14个回答

754

Git检测到重命名而不是将操作保留在提交中,因此无论您使用git mv还是mv都无所谓,只要移动操作与对文件的任何更改分开提交。

log命令采用--follow参数,在重命名操作之前继续历史记录,即使用启发式搜索查找相似内容。

要查找完整的历史记录,请使用以下命令:

git log --follow ./path/to/file

71
我猜想这是出于性能考虑。如果您不需要完整的历史记录,那么扫描内容肯定需要更长时间。最简单的方法是设置别名 git config alias.logf "log --follow",然后只需写入git logf ./path/to/file - Troels Thomsen
20
@TroelsThomsen,Linus Torvalds在这封电子邮件中(链接为http://article.gmane.org/gmane.comp.version-control.git/217),表示这是Git的有意设计选择,因为这种方式比跟踪重命名等功能更强大。该答案链接为https://dev59.com/vG025IYBdhLWcg3w4aCu#10130671。 - Emil Lundberg
175
这个答案有点误导人。Git 确实“检测重命名”,但是在很晚的阶段才会检测到;问题是如何确保 Git 跟踪重命名,读者很容易推断 Git 会自动检测并记录它们,但实际上不会。Git 对重命名没有真正的处理方式,而是有合并/日志工具来试图弄清楚发生了什么事情,并很少得到正确的结果。Linus 对于为什么 Git 不应该按照正确的方式明确跟踪重命名有一个错误但坚定的论点。所以,我们被卡在这里了。 - Chris Moschini
38
重要提示:如果您重命名一个目录,比如在重命名Java包时,请确保执行两个提交。第一个提交是针对 'git mv {old} {new}' 命令,第二个提交是针对所有引用更改后的包目录的Java文件进行更新。否则,即使使用 --follow 参数,Git也无法跟踪单个文件。 - nn4l
63
尽管Linus可能犯的错误很少,但这似乎是一个例外。简单地重命名一个文件夹会导致大量差异上传到GitHub,这让我对重命名文件夹感到谨慎......但这对于一个程序员来说是一种相当大的限制。偶尔,我必须重新定义某些东西的含义,或者改变事物的分类方式。Linus说:“换句话说,我是正确的。我总是正确的,但有时我比其他时候更正确。当我说‘文件并不重要’时,我真的是非常正确的(商标)。”...我对这个表示怀疑。 - Gabe Halsmer
显示剩余19条评论

204

不行。

简单回答,不可能在Git中重命名文件并保留历史记录。这非常麻烦。

有传言称使用 git log --follow--find-copies-harder 可以解决问题,但对我无效,即使文件内容没有任何更改,并且移动是使用git mv完成的。

(最初我使用Eclipse一步重命名和更新包,这可能会混淆Git。但那是非常普遍的事情。如果只执行了一个mv然后commit,而且mv距离不太远,--follow似乎可以工作。)

Linus说你应该全面理解软件项目的全部内容,不需要跟踪单个文件。但是,遗憾的是,我的小脑袋做不到这一点。

让人非常恼火的是,有很多人毫无思考地重复了Git自动跟踪移动的声明。他们浪费了我的时间。Git根本不会这样做。Git的设计是不跟踪移动。

我的解决方案是将文件重命名回其原始位置。让软件适应源代码控制。在Git中,您似乎需要一次性“git”正确。

很不幸,这会破坏Eclipse,因为它似乎使用--follow。有时即使git log可以显示全部历史记录,git log --follow在处理具有复杂重命名历史的文件时也无法完全显示。(我不知道为什么。)

(有一些过于聪明的技巧可以回溯并重新提交旧作品,但它们相当可怕。请参阅GitHub-Gist: emiller/git-mv-with-history。)

简而言之:如果Subversion这样做是错误的,那么Git这样做也是错误的——这不是某种(误!)功能,而是一个错误。


3
我认为你是正确的。我只是想尝试使用php-cs-fixer来重新格式化我的Laravel 5项目的源代码,但它坚持要更改命名空间子句的大写形式以匹配app文件夹的小写值。但是,命名空间(或composer自动加载)只能使用CamelCase。我需要更改文件夹的大写形式为App,但这会导致我的更改丢失。这只是最微不足道的例子,但说明了git启发式算法甚至无法跟随最简单的名称更改(--follow和--find-copies-harder应该成为规则而不是例外)。 - Zack Morris
20
Git -1,Subversion +1 的含义是,相对于 Git,Subversion 更优秀。 - Cosmin
7
这个还是有效的吗?这更加让我现在留在TFS,保留移动/重命名文件的历史记录对于一个大项目来说是必须的。 - Cesar
5
可以,Git当前版本也支持“git log --follow”。我同意@MohammadDehghan的说法。 - insung
6
git log --follow 对我来说有效,但前提是 git mv 将文件移动到未被跟踪的位置。如果你尝试使用 rm a.txt && git mv b.txt a.txt,那么 b.txt 的历史记录将会被破坏。如果想要让 git log --follow 生效,你必须先执行 git rm a.txt 并提交,然后再执行 git mv b.txt a.txt - Gillespie
显示剩余4条评论

123
可以重命名文件并保留历史记录,但这将导致整个存储库的历史记录中文件的名称被更改。这可能只适用于热衷于git-log的人,并且会产生一些严重的影响,包括以下内容:
- 可能会重写共享历史记录,这是在使用Git时最重要的禁忌。如果其他人克隆了存储库,您将通过此操作使其失效。他们必须重新克隆以避免麻烦。如果重命名很重要,这可能是可以接受的,但您需要仔细考虑——您可能会惹怒整个开源社区! - 如果您之前在存储库历史记录中使用旧名称引用该文件,则实际上会破坏早期版本。为了解决这个问题,您需要做更多的工作。它不是不可能,只是繁琐且可能不值得。
现在,因为您还在阅读,您可能是一个单独开发者正在重命名完全隔离的文件。让我们使用filter-tree移动文件!
假设您要将文件old移动到文件夹dir中并将其命名为new。
这可以通过git mv old dir/new && git add -u dir/new来完成,但这会破坏历史记录。
相反:
git filter-branch --tree-filter 'if [ -f old ]; then mkdir dir && mv old dir/new; fi' HEAD

将分支中的每个提交重新执行,对于每次迭代在引号中执行命令。这样做时可能会出现很多问题。我通常测试文件是否存在(否则还没有移动),然后执行必要的步骤来使树适合我的喜好。在这里,您可能需要通过文件进行sed以更改对文件的引用等操作。尽情发挥吧!:)
完成后,文件被移动且日志完好无损。你感觉像一个忍者海盗。
另外; 如果将文件移动到新文件夹中,则只有mkdir dir是必需的。如果文件早于此文件夹的历史记录存在,则if将避免创建此文件夹。

71
作为一个痴迷于 git-log 的人,我不会采取这种方法。在那些时间点上,文件没有被命名为那个名称,因此历史记录反映了一个不存在的情况。谁知道以前的测试可能会出现什么问题!破坏早期版本的风险在很多情况下都不值得。 - Vincent
9
@Vincent 你说得完全正确,我已经尽可能清楚地表达了这个解决方案不太合适的可能性。我也认为在这种情况下我们谈到了“历史”这个词的两个意思,我都欣赏。 - Øystein Steimler
10
我发现有些情况下需要这样做。比如我在自己的个人分支上开发了一些东西,现在想将其合并到主分支上。但是我发现文件名不太合适,于是我在整个个人分支上都修改了文件名。这样可以保持干净的历史记录,并从一开始就使用正确的名称。 - user2291758
5
@user2291758 这就是我的使用案例。这些更强大的 git 命令很危险,但并不意味着它们没有非常有吸引力的用例,如果你知道你在做什么的话! - felipecrv
3
“mv”命令用于在整个代码库的历史记录中每次提交之前移动文件,因此使用普通的Unix“mv”是正确的方式。我甚至不确定如果使用“git mv”会发生什么。如果您正在使用Windows,则应使用“move”命令。 - Øystein Steimler
显示剩余4条评论

49
git log --follow [file]

会通过重命名显示历史记录。


42
看起来这需要你在开始修改文件之前仅提交重命名。如果你移动文件(在 shell 中)并且然后再修改它,一切都没有保障了。 - yoyo
30
因为Git不追踪文件重命名,它会检测到文件名的变化。使用 git mv 实际上相当于执行了 git rm && git add 的操作。可以使用 -M90--find-renames=90 等选项来判断文件是否被重命名,即当文件内容相似度达到90%时认为该文件已重命名。 - vdboor

30

我做:

git mv {old} {new}
git add -u {new}

4
-u 对我似乎没有任何作用,它是用来更新历史记录的吗? - jeremy
1
也许您想要 -A 的行为?再次参见此处:http://git-scm.com/docs/git-add - James M. Greene
1
它确实添加了文件,但它没有更新历史记录,因此“git log 文件名”显示完整历史记录。仍然只有使用--follow选项才会显示完整的历史记录。 - jeremy
3
我进行了一次复杂的重构,移动了一个包含目录(使用mv命令,而不是git mv),然后更改了已经更名文件中大量的#include路径。Git无法找到足够的相似之处来追踪历史记录。但是git add -u正是我所需要的东西。现在git状态显示“重命名”,而之前则显示“删除”和“新文件”。 - AndyJost
2
在SO上有很多关于git add -u用途的问题。Git文档往往没有什么帮助,是我最不想看的地方。这里有一篇文章展示了git add -u的使用:https://dev59.com/YnI-5IYBdhLWcg3w9tdw#2117202。 - Brent Bradburn
显示剩余3条评论

24

I would like to rename/move a project subtree in Git moving it from

/project/xyz

to

/components/xyz

If I use a plain git mv project components, then all the commit history for the xyz project gets lost.

不会(8年后,Git 2.19,2018年第三季度),因为Git将会检测到目录重命名,并且这已经被更好地记录下来。

请参阅commit b00bf1ccommit 1634688commit 0661e49commit 4d34dffcommit 983f464commit c840e1acommit 9929430(2018年6月27日),以及commit d4e8062commit 5dacd4a(2018年6月25日),作者为Elijah Newren(newren
(由Junio C Hamano -- gitster --commit 0ce5a69中合并,于2018年7月24日) 这在Documentation/technical/directory-rename-detection.txt中有解释:
例子:
当所有的x/ax/bx/c都移动到z/az/bz/c时,很可能同时添加的x/d也想通过获取整个目录“x”移动到“z”的提示来移动到“z/d”。
但还有许多其他情况,比如:
历史记录的一侧将x -> z重命名,而另一侧将某个文件重命名为x/e,导致合并需要进行传递重命名。
为了简化目录重命名检测,Git强制执行这些规则:
一对基本规则限制了目录重命名检测适用的范围:
  1. 如果在合并的两个分支上,某个目录仍然存在,则我们认为它没有被重命名。
  2. 如果要重命名的文件的子集中有一个文件或目录阻碍了路径(或彼此之间会相互阻碍),则对于这些特定子路径关闭目录重命名并向用户报告冲突。
  3. 如果历史的另一侧将目录重命名为您的历史记录重命名的路径,则忽略来自历史的另一侧的该特定重命名以进行任何隐式目录重命名(但向用户发出警告)。

您可以在t/t6043-merge-rename-directories.sh中看到很多测试,这也指出:

  • a) 如果重命名将一个目录拆分为两个或更多个目录,则具有最多重命名的目录“获胜”。
  • b) 如果路径是合并的任一侧上的重命名源,则避免对该路径进行目录重命名检测。
  • c) 仅在历史记录的另一侧执行重命名时,才对目录应用隐式目录重命名。

18

是的

  1. 使用git log --pretty=email将文件的提交历史转换为电子邮件补丁。
  2. 您可以重新组织这些文件并将它们重命名到新目录中。
  3. 使用git am将这些文件(电子邮件)转换回Git提交,以保留历史记录。

限制

  • 标签和分支不被保留。
  • 在路径文件重命名(目录重命名)时,历史记录被截断。

逐步解释及示例

1. 以电子邮件格式提取历史记录

示例:提取file3file4file5的历史记录。

my_repo
├── dirA
│   ├── file1
│   └── file2
├── dirB            ^
│   ├── subdir      | To be moved
│   │   ├── file3   | with history
│   │   └── file4   | 
│   └── file5       v
└── dirC
    ├── file6
    └── file7

设置/清理目标位置

export historydir=/tmp/mail/dir       # Absolute path
rm -rf "$historydir"    # Caution when cleaning the folder

提取每个文件的历史记录并以电子邮件格式呈现

cd my_repo/dirB
find -name .git -prune -o -type d -o -exec bash -c 'mkdir -p "$historydir/${0%/*}" && git log --pretty=email -p --stat --reverse --full-index --binary -- "$0" > "$historydir/$0"' {} ';'

抱歉,选项--follow--find-copies-harder无法与--reverse组合使用。这就是为什么在文件重命名(或父目录重命名)时会截断历史记录。

电子邮件格式的临时历史记录:

/tmp/mail/dir
    ├── subdir
    │   ├── file3
    │   └── file4
    └── file5

Dan Bonachea 建议在第一步中颠倒 git log 生成命令的循环顺序:不要为每个文件运行git log,而是在命令行上使用文件列表运行它一次并生成一个单一的统一日志。这样修改多个文件的提交将在结果中保持单个提交,并且所有新提交将保持其原始相对顺序。请注意,当重新编写(现在统一的)日志中的文件名时,这也需要在下面的第二步中进行更改。


2. 重新组织文件树和更新文件名

假设您想将这三个文件移动到另一个存储库中(可以是同一个存储库)。

my_other_repo
├── dirF
│   ├── file55
│   └── file56
├── dirB              # New tree
│   ├── dirB1         # from subdir
│   │   ├── file33    # from file3
│   │   └── file44    # from file4
│   └── dirB2         # new dir
│        └── file5    # from file5
└── dirH
    └── file77

因此,请重新组织您的文件:

cd /tmp/mail/dir
mkdir -p dirB/dirB1
mv subdir/file3 dirB/dirB1/file33
mv subdir/file4 dirB/dirB1/file44
mkdir -p dirB/dirB2
mv file5 dirB/dirB2

您的临时历史记录如下:

/tmp/mail/dir
    └── dirB
        ├── dirB1
        │   ├── file33
        │   └── file44
        └── dirB2
             └── file5

同时也要在历史记录中更改文件名:

cd "$historydir"
find * -type f -exec bash -c 'sed "/^diff --git a\|^--- a\|^+++ b/s:\( [ab]\)/[^ ]*:\1/$0:g" -i "$0"' {} ';'

3. 应用新历史记录

您的其他仓库为:

my_other_repo
├── dirF
│   ├── file55
│   └── file56
└── dirH
    └── file77

应用来自临时历史文件的提交:

cd my_other_repo
find "$historydir" -type f -exec cat {} + | git am --committer-date-is-author-date

--committer-date-is-author-date 保留原始提交时间戳 (Dan Bonachea的评论)。

您的其他代码库现在是:

my_other_repo
├── dirF
│   ├── file55
│   └── file56
├── dirB
│   ├── dirB1
│   │   ├── file33
│   │   └── file44
│   └── dirB2
│        └── file5
└── dirH
    └── file77

使用 git status 命令查看准备推送的提交数量 :-)


额外技巧:检查您的存储库中已重命名/移动的文件

要列出已重命名的文件:

find -name .git -prune -o -exec git log --pretty=tformat:'' --numstat --follow {} ';' | grep '=>'

更多自定义:你可以使用选项--find-copies-harder--reverse完善git log命令。你也可以使用cut -f3-删除前两列,并使用完整模式'{.* => .*}'进行筛选。

find -name .git -prune -o -exec git log --pretty=tformat:'' --numstat --follow --find-copies-harder --reverse {} ';' | cut -f3- | grep '{.* => .*}'

4
注意:这种技术将更改两个或多个文件的提交拆分为单独的碎片提交,并按文件名排序以打乱它们的顺序(因此原始提交的碎片在线性历史记录中不相邻)。因此,仅从每个文件的角度来看,所得到的历史记录才是“正确”的。如果您移动了多个文件,则结果历史记录中的所有新提交都不能代表原始存储库历史记录中曾经存在的移动文件的一致快照。 - Dan Bonachea
3
你好@DanBonachea。感谢你提供有趣的反馈。使用这种技术,我已成功迁移了一些包含多个文件的存储库(甚至包括重命名的文件和跨目录移动的文件)。您认为我们应该在此答案的顶部添加一个警告标语来解释这种技术的局限性吗?致意。 - oHo
2
我采用了这种技术来避免问题,即通过反转第一步中git日志生成命令的循环。也就是说,不是针对每个文件运行git log一次,而是在命令行上使用文件列表运行一次,并生成一个统一的日志。这样,修改两个或更多文件的提交将保持为结果中的单个提交,并且所有新提交都保持其原始相对顺序。请注意,这还需要在第二步中更改重写(现在统一的)日志中的文件名。 我还使用了git am --committer-date-is-author-date以保留原始提交时间戳。 - Dan Bonachea
2
感谢您进行实验并分享。我稍微更新了答案,以便其他读者参考。不过,我花了一些时间来测试您的处理过程。如果您想提供命令行示例,请随时编辑此答案。祝好!;) - oHo

15

我遇到了一个问题"重命名文件夹而不丢失历史记录"。要解决它,请运行:

$ git mv oldfolder temp && git mv temp newfolder
$ git commit
$ git push

3
应该将这个答案标记为正确的。对我来说,在同一个代码库中将文件从一个文件夹移动到另一个文件夹完全有效。我甚至不需要做“temp”这件事情。git mv olddir/file newdir/file 对我起作用了。 - John Livermore
所有历史记录都已保存。 - John Livermore
30
为什么这比“git mv oldfolder newfolder”更好? - Eliezer Berlin
想要注意一下,这个方法也适用于文件。我刚刚进行了重命名操作,并且使用了@EliezerBerlin上面的回答,历史记录得以保留。 - JimmyV

14

我按照以下多步骤过程将代码移动到父目录并保留了历史记录:

步骤0:从'master'创建一个名为'history'的分支,以备不时之需

步骤1:使用git-filter-repo 工具重写历史。下面的命令将 'FolderwithContentOfInterest' 文件夹移动到上一级并修改了相关提交历史记录。

git filter-repo --path-rename ParentFolder/FolderwithContentOfInterest/:FolderwithContentOfInterest/ --force

步骤2:此时GitHub存储库失去了其远程存储库路径。添加远程引用。

git remote add origin git@github.com:MyCompany/MyRepo.git

第三步:拉取仓库信息

git pull

步骤 4:将本地丢失的分支与源分支连接起来

git branch --set-upstream-to=origin/history history

步骤5:如有需要,请解决文件夹结构的合并冲突

步骤6:进行推送!!

git push
注意:修改的历史和移动的文件夹似乎已经提交。 输入代码 完成。代码移动到父目录/所需目录,保持历史记录完整!

作为2020年的答案之一,filter-repo是执行此类操作的最佳方式,因此它应在答案列表中排名更高。 - mprost

12

要重命名目录或文件(我不知道复杂情况,可能会有一些注意事项):

git filter-repo --path-rename OLD_NAME:NEW_NAME

要重命名在提到该目录的文件中的目录(可以使用回调函数,但我不知道如何):

git filter-repo --replace-text expressions.txt

expressions.txt是一个文件,其中每行有类似于literal:OLD_NAME==>NEW_NAME的内容(可以使用Python的RE和regex:或者glob和glob:)。

要重命名提交消息中的目录:

git-filter-repo --message-callback 'return message.replace(b"OLD_NAME", b"NEW_NAME")'

Python的正则表达式也被支持,但必须手动用Python编写。

如果存储库没有远程副本,则需要添加--force来强制重写。(在执行此操作之前,您可能需要创建存储库的备份。)

如果不想保留引用(它们将在Git GUI的分支历史记录中显示),则需要添加--replace-refs delete-no-add


git: 'filter-repo' is not a git command. See 'git --help' - alper
@alper 这个命令可行!但是 filter-repo 不是 Git 的标准命令。在使用之前,您需要安装它。您可以在此处找到下载和安装说明 https://github.com/newren/git-filter-repo - John

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