我正在嘗試洗掉不小心包含在舊提交中的檔案(不是上一次或最后一次提交),但與我想保留的其他檔案發生了很大的沖突。我只想洗掉不需要的檔案并將我想要的檔案保留在特定的提交中。我正在使用VS2022。
假設我的本地功能分支MyBranch有提交:A -> B -> C -> D -> E. 所有提交也被推送到遠程MyBranch分支。
提交C有file1, file2, file3 and file4. 我只想洗掉不需要的檔案 2、3、4,并將 file1 保留在C本地和遠程分支中。MyBranch是我的私人功能分支,除了我之外沒有其他人在使用它。如果我恢復提交C,file1會有很多合并沖突。我想知道是否有辦法在本地重寫歷史記錄并更新遠程,就好像MyBranch從未包含不需要的檔案 2、3、4。謝謝
uj5u.com熱心網友回復:
TL;博士
使用git rebase -i和git push --force-with-lease或類似。
長
沒有任何東西,甚至 Git 本身,都不能改變任何現有的提交。但是這里并沒有丟失所有內容,這只是意味著您的作業更加復雜。
你畫了一組提交,我會這樣改寫:
...--o--● <-- main
\
A -> B -> C -> D -> E <-- MyBranch, origin/MyBranch
重要的是要意識到連接提交的箭頭——就像A to B在你的繪圖中一樣——都是向后的,并且是后來提交的一部分。這是必要的,因為一旦提交,就無法更改。它包含一個箭頭,指向您開始分支的分支上的最后一次提交——我在上面使用的那個——并且它將永遠向后指向那個提交。所以更準確的繪圖如下所示:AMyBranch●
...--o--● <-- main
\
A <-B <-C <-D <-E <-- MyBranch, origin/MyBranch
(我們很懶惰并且沒有正確繪制早期提交的箭頭,部分原因是我的向上和向左箭頭喜歡并且↖?看起來有點蹩腳)。除了每次提交中出現的這些向后指向的“箭頭”之外,每次提交都包含每個檔案的完整快照,1因此,您在 commit 中添加的檔案很可能是您不想要的,也可能存在于 commits和.??CDE
無論如何,這里的最終問題是您實際上無法更改 commit C。它將始終擁有這些檔案并始終指向B. 提交D將永遠擁有它擁有的任何東西,并且永遠指向C,提交E將永遠擁有它擁有的任何東西,并且永遠指向D。但是......如果你提取了 commit C,修復了一些東西,并做出了一個新的和改進的commit 怎么辦。讓我們稱這個新的和改進的 commit C',并把它畫進去:
...--o--● <-- main
\
A--B--C--D--E <-- MyBranch, origin/MyBranch
\
C' <-- improved-branch
Now we want to take existing commit D and improve it very similarly: new commit D', our copy of existing D, should do to C' whatever D did to C, and should point backwards to C':
...--o--● <-- main
\
A--B--C--D--E <-- MyBranch, origin/MyBranch
\
C'-D' <-- improved-branch
We repeat once more for commit E to get:
...--o--● <-- main
\
A--B--C--D--E <-- MyBranch, origin/MyBranch
\
C'-D'-E' <-- improved-branch
and then we get rid of the name improved-branch and make the name MyBranch find commit E' instead of finding commit E:
...--o--● <-- main
\
A--B--C--D--E <-- origin/MyBranch
\
C'-D'-E' <-- MyBranch
1Each commit:
- is numbered, with a big, ugly, random-looking (but entirely not random), cryptographic-checksum hash ID;
- is immutable;
- contains two things: a full snapshot of every file, and some metadata.
The metadata give stuff like the name and email address of the author of the commit and the date-and-time for when they made it. It includes the log message they (you) wrote at that time. And, to make those "arrows", each commit has a list of previous commit hash IDs. Most commits have exactly one entry in this list, and that's our "arrow" coming out of the commit: the hash ID lets Git use this commit to find the previous commit.
Since each commit holds a full snapshot of every file—with the file contents de-duplicated within and across commits—Git can simply compare the snapshots in A and B, for instance, to see which files are the same and which are different. Git then shows you only the different files, and does that by computing a git diff to show you, rather than showing you the entirety of each file in each commit. But that diff is not what the commit stores. It actually has a full copy of every file (with the de-duplication taking care of the obvious objection, that this would fill up your disk way too fast).
Using git rebase to get this far
The command that actually copies commits (e.g., to turn E into E') is git cherry-pick, but we have to use it multiple times—three, in this case. We'd like Git's power tool here, and that's Git's interactive rebase. We run:
git switch MyBranch # or git checkout MyBranch, if/as needed
and then:
git rebase -i HEAD~3 # 3 here means "count back 3 times from `E`
This brings up an instruction sheet that has three pick commands. These correspond to running git cherry-pick, which is Git's built-in command for making a copy of some commit. We don't want a full copy of C though, so we have to change that first pick command to edit, then write out this set of instructions and exit the editor,2 which makes git rebase start the whole process and do the first cherry-pick, but then stop for amending. We can now run:
git rm file2 file3 file4
and then:
git commit --amend
(which is a bit of a lie,2 but gets us C') and then:
git rebase --continue
which finishes the job—the remaining two commits are still marked pick—and gets us the desired result:
...--o--● <-- main
\
A--B--C--D--E <-- origin/MyBranch
\
C'-D'-E' <-- MyBranch
2With some editors, you don't actually exit the editor, you just have the editor signal back to Git that the instruction sheet is done. The details depend on your editor. The git commit command, which brings up an editor for you to write your commit log message, works the same way, so whatever you are using for that—as long as it is not -m or something—will work here as well.
3Git cannot change any existing commit, and git commit --amend is no different. That's why --amend is a lie. What --amend does is:
- make a new commit, wherever we are now, but
- instead of having the new commit point backwards to the current commit, have it point backwards to the current commit's parent(s).
Also, git rebase -i will "cheat" if it can and not actually copy a commit, if that's possible. So when we changed pick to edit and wrote out the instructions and exited, Git didn't actually bother to copy C. It just got us into "detached HEAD" mode with C being the current commit, like this:
...--o--● <-- main
\
A--B D--E <-- MyBranch, origin/MyBranch
\ /
C <-- HEAD
Our git commit --amend uses whatever is in Git's index aka staging area, so git rm file2 file3 file4 updates that and then we run the git commit --amend command. This makes new C' with the same parent that C has—i.e., B—and points HEAD to C':
...--o--● <-- main
\
A--B--C--D--E <-- MyBranch, origin/MyBranch
\
C' <-- HEAD
When we run git rebase --continue, Git picks up from wherever it left off in the instructions. That has two more pick commands, for D and E, so the rebase now does normal cherry-picks for these: no shortcut is allowed here. So at the end of the cherry-picking sequence, rebase has:
...--o--● <-- main
\
A--B--C--D--E <-- MyBranch, origin/MyBranch
\
C'-D'-E' <-- HEAD
Now that rebase has reached the end of the instructions successfully, it yanks the branch name MyBranch off of wherever it was before (commit E) and pastes it on here (at E'), then "re-attaches" Git's HEAD:
...--o--● <-- main
\
A--B--C--D--E <-- origin/MyBranch
\
C'-D'-E' <-- MyBranch (HEAD)
which is how I usually draw these things.
Your own repository is now repaired; GitHub's is not, yet
You now have the commits you want (plus some you don't want, but you cannot do anything about that) in your repository. You now need to send the new commits to GitHub, so that they can put them in their repository (the one you control over there). They don't exist there yet.
Normally you would just run:
git push origin MyBranch
which would have your Git call up their Git, enumerate any commits you have that they don't—in this case that's C', D', and E'—and send those commits and ask them to set their name MyBranch, which your Git remembers as origin/MyBranch.
If you do this now, though, you'll see the commits do get sent, but then GitHub rejects the request to update the name MyBranch:
! [rejected] MyBranch -> MyBranch (non-fast-forward)
This is Git's way of saying "they complained that if they obeyed your polite request to update their MyBranch, they'd lose some commits off the end". The commits they would lose are, of course, commits C-D-E: exactly the ones you want them to lose.
To make them give up those commits, you need to use one of the "force" variants of git push, so that instead of sending a please, if it's OK, update your name MyBranch request, you send an update your name MyBranch! Do it now, dammit! command. That's git push --force.
To be more careful—which in this case you won't need, but it's usually wise to be careful with sharp saws like --force—you can use --force-with-lease. This sends, instead of either a polite request or an overriding command, a compromise: I think your branch name MyBranch identifies commit _______ (fill in the blank with the hash ID for E). If I'm right, change it to _______ (fill in the blank with another hash ID, this time for E'), even if that loses commits off the end of the branch. Let me know whether you did it. They will now make this check. Note that your Git supplies the hash ID for E based on your origin/MyBranch name, and supplies the hash for E' based on the fact that you ran:
git push --force-with-lease origin MyBranch
That is, the name MyBranch here supplied both hash IDs: one directly, and one via your Git looking up your origin/ variant of that name.
Using --force-with-lease takes care of the problem that occurs with a shared GitHub (or other site) repository to which multiple people might push commits. If someone else added on a commit F while you were fixing C-D-E to become C'-D'-E', your git push --force-with-lease origin MyBranch would fail, because your Git would send the hash ID of E when they actually now hold the hash ID of F. You can then run git fetch to obtain the new commit(s) and git cherry-pick them to your updated branch and try the --force-with-lease again.
Since nobody else is writing to this GitHub repository, you don't need the --force-with-lease, but it's good to know about.
轉載請註明出處,本文鏈接:https://www.uj5u.com/qukuanlian/456760.html
上一篇:如何重新基于較早的遠程提交?
