假設我有 2 個本地存盤庫。
Local Repository A //Cloned from production remote repository.
Local Repository B //Cloned from development remote repository.
從本地存盤庫 A,我創建了自己的功能分支,名為
FeatureA //In Local Repository A
從本地存盤庫 A 完成 FeatureA 后,我想將此 FeatureA 分支合并到一個名為的分支中
Developer //In Local Repository B
請注意,FeatureA 和 Developer 兩個分支如何位于不同的本地存盤庫中。
如何將 FeatureA 分支合并到 Developer 分支?
uj5u.com熱心網友回復:
Git 實際上并不合并分支。事實上,就像 Git 中的大多數事情一樣,分支在這里根本不重要:只有提交才重要。Git 合并提交。
這對您來說意味著只有在所有提交都相關時才能獲得正確的結果。(他們可能是,但我們看不到。 你可以找到。)
長
關于提交有一些重要的事情需要了解(這不是一個完整的串列,但在這里對于合并很重要):
- 它們是通過哈希 ID 找到的。
- 散列 ID 很大、很丑,而且看起來很隨機(實際上不是隨機的,但非常不可預測,并且人類無法使用)。
- 提交通過哈希 ID 參考其他較早的提交。這對于提交本身來說很好,畢竟它們是由計算機程式(或一系列計算機程式:我們稱之為程式
git)讀取的,但對于那些被認為負責這些程式和計算機的不幸的人類來說并沒有多大好處。
由于這些特殊的要點,Git 對人類做出了很大的讓步:它允許我們使用分支名稱來查找提交。不僅如此,它還有一個專為人類設計的特殊功能。什么時候我們:
- 首先指示 Git “在”某個特定分支,然后
- 直接 Git 進行新的提交,
Git 將更新我們“打開”的分支名稱,以便該名稱現在指的是新的提交,而不是指剛才它所指的任何特定提交。
對于普通的單親提交,我們可以把這種情況畫成這樣。我們首先將真實提交所具有的真實哈希 ID 替換為我們選擇以與我們虛弱的人類大腦一起作業的虛假單字母偽 ID 。然后我們用一個箭頭來繪制每個提交,向后指向一個較早的提交。這將最新的提交放在右側:
... <-F <-G <-H
所以這里H代表最新提交的哈希ID 。在commit的內部表示中的某個地方H,Git 保存了早期 commit 的真實哈希 ID G。我們將其繪制為從 的表示中出來的箭頭H,指向我們的 表示G。
當然,G它本身也是一個提交,所以它也有一個存盤的先前提交的哈希 ID:G指向F. F同樣指向一些較早的提交,依此類推。這將永遠重復,或者更確切地說,直到我們回到有史以來的第一次提交:A在這個公式中提交。(因此,我們的存盤庫只有微不足道的 8 次提交。)
出于 StackOverflow 的目的,由于懶惰和/或字體問題,我傾向于停止將提交到提交箭頭繪制為箭頭,而是這樣做:
A--B--...--G--H
但實際上,從提交到提交的每個連接都只有一種方式:從較晚的提交,例如H,到較早的提交。這是因為提交一旦完成,就完全是 100% 只讀的。沒有哪一個位在提交都不能更改。1
當我們為這些圖紙添加分支名稱時,它們的作業方式變得更加清晰。假設我們有兩個名字,mainanddevelop和 * 兩個名字都指向 commit H,像這樣:
...--G--H <-- main, develop
This means all commits up through and including H are on both branches. We must now pick one branch to be "on", using git checkout or git switch:3
git switch main
To remember which branch we're using, we add the special name HEAD, written in all uppercase like this, attached to just one of these branch names:
...--G--H <-- main (HEAD), develop
This indicates that we are using commit H via the name main.
The checkout or switch command works by (very roughly):
- removing from the work area—which Git calls the working tree—all the files that came out of some other commit, if / as needed;
- filling in this work area with all the files from the commit we've just switched to.
We'll see this in action in a moment, but for now, let's switch to develop, or even create a new branch name feature.
1This read-only property is required to make the hashing scheme work. The hash ID of a commit is simply a cryptographic checksum of all the bits stored in that commit. If you take a commit out of a Git database, turning it into ordinary data, then modify that data in some way and put it back into the Git database, what you get is a new and different commit with a new and different hash ID. The old commit remains, unchanged, under the old ID.
Git verifies, at object-extraction time, that all the bits that come out of the database still checksum to the original value. If they don't, Git declares the database corrupt, and ceases to function. Since file contents are also stored using this same hashing trick, that's how we can be sure that none of our files are ever damaged. Once they're in the repository, they're in there forever2 and can never be changed.
2Technically, it's possible to strip commits out of a repository database, but it's tricky and we won't cover it here.
3There's no difference here between these uses of checkout and switch. Certain historical mistakes with git checkout were eventually cleaned up by splitting that one command, checkout, into two separate commands, switch and restore, and it makes sense to learn the new ones as long as you are not forced to use an old version of Git that lacks the new ones. (I have been using Git for more than 15 years at this point though so I have old and sometimes not-so-good habits here. If I use git checkout, it's by habit, or because someone gave me a Git 1.7 version to update, perhaps.)
Making new commits on a branch
If we now switch to existing branch develop, we get:
...--G--H <-- main, develop (HEAD)
To do this, Git would need to remove all the files that came out of commit H, and instead, put in all the files from commit H. This kind of remove-and-replace-with-sameness is obviously stupid, so Git skips this step for this particular case.4 Git doesn't remove or replace any files at all this time. So if we start making changes but forget to switch to a different branch (or create a new branch; see below) it's generally safe to do that as soon as you notice your error.
Anyway, now that we're on develop, let's make a new commit in the usual way. I will skip over a lot of important detail—in particular, I won't mention Git's index aka staging area—and will just assume that you know everything there is to know here;5 and however, we do it, we now have Git make a new commit, which we will call I.:
I
/
...--G--H
New commit I points backwards to existing commit H. But now the special magic trick happens: Git writes the new commit's hash ID into the current branch name, i.e., the one that has HEAD attached. So to complete our drawing—and see why I put I on a line by itself here—we draw this:
I <-- develop (HEAD)
/
...--G--H <-- main
Note how the name develop now points to the new commit. All the other branch names are untouched: only the name develop moved.
If we make a second new commit J, we get:
I--J <-- develop (HEAD)
/
...--G--H <-- main
Commits up through H are on both branches, while new commits I-J are only on develop.
If we now run git checkout main or git switch main, we get this:
I--J <-- develop
/
...--G--H <-- main (HEAD)
This time, Git really does have to remove some files—those specific to commit J—and replace them with the right files for commit H. So Git does that, and if we now examine our files, we'll see that we have the files from commit H.6
Now that we're back on commit H via the name main, let's make a new branch name. We have to pick some commit for this new name to point-to, and the usual choice is "the commit we're on now", i.e., commit H:
git switch -c feature # or git checkout -b feature
This leaves us in this state:
I--J <-- develop
/
...--G--H <-- feature (HEAD), main
If we now make two more commits, we get:
I--J <-- develop
/
...--G--H <-- main
\
K--L <-- feature (HEAD)
We now have a state in which merging makes sense.
4This skipping is achieved in a smarter way than described here, but for the "switch branches without switching commits", the effect is that the change is always allowed. In more complicated cases, you get odd effects; see Checkout another branch when there are uncommitted changes on the current branch for the gory details.
5There is a lot to know! For more details, see other answers or Git tutorials.
6This is where the complications mentioned in footnote 4 can come in. Files that we forgot to commit, or deliberately didn't commit, can be carried along in the working tree and/or in Git's index aka staging-area. But if we made sure to commit everything in I and J, we'll be in a "clean" state, as reported by git status, before and after the checkout—unless things get really complicated, but let's not go there.
Merging is about combining work
Let's draw this again but change some names, switch branches around, and drop the name main entirely (it's in the way):
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
We're now using commit J via name br1. We can run:
git merge <hash-of-L>
or:
git merge br2
to have Git locate commit L and do the work of a merge. Or, if we aren't ready to merge commit L yet, but want to merge in commit K, we can run:
git merge <hash-of-K>
That is, we'd run git log br2 and see commit L, then see commit K. It has some big ugly hash ID, b789abc... or whatever, and we'd grab that with the mouse, cut-and-paste style, and produce a command like:
git merge b789abc
(abbreviated hash IDs work too, so you can retype the first 4 or 7 or 15 characters and stop, but it's way too easy to make a mistake here: I always use cut-and-paste for this).
We generally don't bother to merge with some number of commits back like this, but in some complicated cases—e.g., if we have:
o--o--...--o <-- br1 (HEAD)
/
...--o--*
\
o--...--(thousands of commits)--...--o <-- br2
we might want to break the merge up into smaller chunks, picking some commit somewhere along the very long line of br2 to merge in first:
o--...--o---M <-- br1 (HEAD)
/ /
...--o--* /
\ /
o--...--o--(hundreds of commits)--...--o <-- br2
Having merged in just 500 commits, we took an original 1400 commits down to 900 left to merge; we can do another 500, leaving only 400 left to merge, etc.
In any case, regardless of how many commits we are merging, the merge operation works the same way. Given:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
and git merge br2, Git:
- finds the current commit
J(that's easy: Git usesHEAD); - finds the other commit
L(that's easy: Git usesbr2, which we gave it); and - finds the merge base commit, commit
H: that's harder.
Git finds that merge base commit through an algorithm, but we can just describe at as the best common ancestor, and in this case it's simply commit H.7
Git now uses the snapshots in each commit—we haven't described this properly, but each commit holds a full snapshot of every file—to figure out "what we changed" on "our branch" br2, by diffing commit H vs commit J, and to figure out "what they changed" on "their branch" by diffing commit H vs commit L. The three commits to diff here are:
- the merge base, on the left of both
git diffcommands; - our current or
HEADcommit, on the right of the "ours" diff; - the commit we chose to merge, on the right of the "theirs" diff.
The output from the two diffs determines the set of changes to merge.
The merge algorithm now combines these changes, applies the combined changes to the snapshot in the merge base commit—commit H here—and thus keeps our changes while adding their changes, which is what we want.
Having successfully combined these two sets of changes and applied them to the merge base, Git now makes a new commit from the result.8 That new commit is a merge commit, which is special in exactly one way: instead of pointing back to a single parent, it points back to two parents, like this:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
The first parent of new merge commit M is the commit we were using when we ran git merge, i.e., commit J. The other parent is the commit we named on the command line, commit L in this case. Merge commit M has, as its snapshot of all files, the result of the combining-and-applying-to-H's-snapshot.
7As the Wikipedia article notes, there is not necessarily a single unique LCA node in a DAG. For these cases, the merge algorithm gets trickier; we won't cover those here.
8If Git fails to combine the changes, it deliberately stops in the middle of the merge, leaving us a mess to clean up. We won't cover that case here either.
What this means for you
For git merge to do its job, the commits you give it must:
- all be in one repository; and
- be related, in terms of having some best shared commit:
Hin our example above.
When you have a single repository, the first condition is trivially satisfied—all the commits are in the (single) repository—and the second is usually the case because we normally make a new branch by growing it from some starting point that's on some existing branch. That shared starting point commit is a common starting point and therefore a shared commit. If there have been merges since then, there may be some better shared commit, but otherwise this is the shared commit:
...--*--*--*--o--o <-- br1 (HEAD)
\
o----o <-- br2
The starred commits * are on both branches, so the rightmost one works as the merge base. Or:
...--*--*--*--o--o--M1--o--o <-- br1 (HEAD)
\ /
*----*----o----o <-- br2
Again, all the starred commits are on both branches. The extra parent of merge commit M1 joins br2 back into br1, so once again, the rightmost starred commit works as the merge base. Once we make merge M2 we have:
...--*--*--*--o--o--M1--o--o--M2 <-- br1 (HEAD)
\ / /
*----*----*----* <-- br2
Note how merging "adds" all the other branch's commits to br1.
When you have two separate repositories, though, are the commits related? Now we get into one of the complications with any distributed version control system, like Git.
When you clone a Git repository, you literally copy the commits. A git clone of some repository R makes some clone C, but C has all the commits from R.9 In Git, cloning copies the commits, but doesn't copy the branches,10 which in some sense is weird—Mercurial copies the branches too, for instance—but the important thing for your case is that the commits get copied.
Now, after the commits are copied into C, someone can make more commits in R, and/or someone else can make more commits in C. But if they both follow the same sort of standard procedure—of starting with the commits they have, and merely adding on—these commits will all "join up in the past", in exactly the same way we get with a single repository.
All you have to do, in this case, is:
- clone either of R or C into a third Git repository, then
- add to that repository all the commits that are in the other of these two repositories, that aren't already in your third clone.
That second step—"add commits that we don't have"—might seem like a big thing. In some ways, it is ... but we already have to do that because of cloning. That is, suppose there's some "source of truth" repository Rcentral that everyone clones. You make your clone Cyou. Alice makes clone Calice, Bob makes clone Cbob, and so on.
At some point, somebody makes new commits, and eventually—somehow—gets their commits into Rcentral. And now everybody with a clone has to get those new commits into their clones, if they want to see them and use them. So we have git fetch.
We run git fetch name. Git calls the name we use here a remote. When you clone Rcentral, your Git, in your clone C, adds a standard remote name, origin. Your Git stores the URL of Rcentral under this standard name, and from now on, you can just run:
git fetch origin
to have your Git call up the Rcentral Git. They will list out their commits (by hash ID) and their branch names (by name), and your Git will figure out if any of those commits are new to you, and if so, obtain them. Your Git will then set up remote-tracking names by taking their branch names, main and feature and whatever, and sticking origin/ in front of them: the origin part comes from the remote. These names "track"11 the branch names over on origin, so they are the remote-tracking names for origin.
You can add more Git repositories as additional remotes. That is, using:
git remote add repo-xyz <url>
you add a second remote, using the name repo-xyz, to store the given url. Now you can run:
git fetch repo-xyz
Your Git will call up the Git at the URL you just saved, ask them about their branch names and commit hash IDs, and bring over any commits they have, that you don't. Your Git will then create, in your clone C, remote-tracking names of the form repo-xyz/*. You'll have a repo-xyz/main if they have a main. If they have a develop, you'll have a repo-xyz/develop.
Each of these remote-tracking names will remember exactly one commit hash ID, just as each branch name in C, Rcentral, or this added remote remembers exactly one commit hash ID. Because git fetch reads their current state, your remote-tracking names will now remember their branch state as of the time you ran git fetch.
So, having run git fetch origin and git fetch repo-xyz, you now have:
- all the commits you had, plus
- all the commits
originhad that you didn't, plus - all the commits
repo-xyzhad that you didn't, plus - remote-tracking names
origin/*andrepo-xyz/*to remember branch names and commit hash IDs fromoriginandrepo-xyz.
Remote-tracking names, which locate specific commits, work just as well as branch names for locating specific commits. So you can pass a remote-tracking name to git merge. The only thing they don't work for here is that you cannot get "on" a remote-tracking name. That's because it is not a branch name, and Git will only let you attach HEAD to a branch name. If you want a branch name to point to some commit that some existing remote-tracking name locates, you can use git checkout or git switch to do this:
git switch -c update-some-abc-branch repo-xyz/abc-branch
Because you have two remotes (origin and repo-xyz), you may run into the annoyance that you can't make one name, like main, that you work with when working with both origin/main and repo-xyz/main. You may need to use some funky mismatched branch names, like I just did above. That works fine: there's no need to use the same names in each repository.12
This gives you all the information you need to:
- create branch names in your repository C to locate specific commits while getting "on" those branches;
- run
git mergewith the commits identified by your branch names and/or your remote-tracking names.
As long as you remember that what Git really cares about are the commits and their hash IDs, and that your branch names are just there to let you find your chosen commits, you'll be fine.
9It's possible to make clones that omit some commits, but we'll consider the usual case where we don't do that.
10Git uses, instead, remote-tracking names. Copying branch names would be possible but would lead to confusion. The remote-tracking name technique leads, instead, to ... confusion. I'm not sure there's that much of an improvement here. ?? But it is what it is.
11 Git 嚴重過度使用了這個動詞track。然而,它又是這樣。Git 將這些東西稱為遠程跟蹤分支名稱,但它們實際上并不是分支名稱,因此我使用遠程跟蹤名稱,因為它們是名稱并且它們確實跟蹤其他(遠程)名稱。
12這就是強制每個人使用相同分支名稱的 Mercurial 方法非常有用的地方。這也是強制每個人使用相同分支名稱的 Mercurial 方法如此有害的地方。這一切都取決于您需要完成什么。
uj5u.com熱心網友回復:
我發布此答案以供將來參考。
我錯誤地認為存在兩個不同的遠程存盤庫(一個用于生產,一個用于開發)。
事實證明,這個專案只有一個遠程存盤庫。
這是我應該做的。
從遠程存盤庫,只將它克隆到我的本地機器一次。
然后在我的本地機器上簽出 Production 分支。
從我剛剛簽出的 Production 分支中創建我自己的分支。
完成該功能后,將我的分支合并到 Developer 分支。
轉載請註明出處,本文鏈接:https://www.uj5u.com/gongcheng/349276.html
