我正在努力理解幾個非常大的存盤庫的歷史,這些存盤庫有數百個(舊)分支從未被洗掉(即使這些分支中的大多數作業已經“完成”)。
我正在嘗試找到一種方法來生成一個分支串列
- 包含創建分支后的提交(“非空”)
- 尚未合并到另一個分支
如果我的假設是正確的,這應該回傳包含未合并/活動代碼的分支串列 - 其他所有內容都可以安全洗掉。
一個不錯的噱頭是通過可視化這一點git log --graph- 僅顯示“當前作業樹”,僅回傳所有“當前活動分支”中存在的第一個提交。
非常感謝任何建議/幫助!
uj5u.com熱心網友回復:
TL;DR:git branch --no-merged HEAD可能是您想要的答案。您可能想要添加-r或-a,或使用除 之外的其他內容HEAD。您可能希望多次運行此(調整后的)命令,為每個分支名稱運行一次(盡管在這種情況下,有一些方法可以更有效地執行此操作,但代價可能是這樣)。
長
重要的是要意識到 Git 實際上并沒有合并分支。或者更準確地說,我們必須先定義分支的含義(請參閱“分支”到底是什么意思?);這取決于我們使用的定義,Git不會有分支機構,或不合并分支機構,或不合并子公司,但隨后他們有時會取消合并后; 或者還有其他可能性,具體取決于您所說的“分支”。?? Git所做的合并——可能對你的問題有用的方式——就是提交。分支名稱可幫助您和 Git查找提交,否則這些提交會單獨存在于提交圖中,這就是你將如何使用上面的答案。
Git存盤庫實際上主要是提交的集合。Git 不是關于檔案——盡管提交確實包含檔案——也不是關于分支,或者至少是分支名稱(與“分支”不同,它是明確定義的),盡管分支名稱可以幫助我們找到提交。這實際上只是關于提交,因此您需要能夠可視化提交:
一個不錯的噱頭是通過
git log --graph
你可以這樣做,但是:
僅顯示“當前作業樹”,僅回傳到所有“當前活動分支”中存在的第一個提交。
該作業樹實際上不是在Git的,并給予多么糟糕分支在首位的定義,再加上事實字活躍是完全未-defined,我們可能永遠不會知道什么是“當前活躍的分支”,甚至手段。所以我們不可能那樣做。
什么是在Git中是提交。提交:
編號:每個都有一個唯一的編號,或哈希 ID,以十六進制表示。一旦將某個哈希 ID 分配給某個特定的提交,這意味著該提交永遠存在于每個 Git 存盤庫中。換句話說,這些提交哈希 ID 是普遍唯一的。1 Git 在這個原則上做了很多事情:例如,我們用
git fetchor將兩個 Git 存盤庫相互連接起來git push,它們只交換原始哈希 ID,并立即知道另一個 Git 需要獲取哪些提交(以及檔案) .是不可變的:任何提交的任何部分都不能改變。(Git 的所有內部物件都是如此,所有這些物件都使用 UUID 散列方案。散列僅在物件不能更改時才有效。)
存盤兩件事:所有檔案的快照(采用特殊的內部只讀重復資料洗掉格式)和一些元資料。元資料包括諸如誰進行了提交以及何時提交等內容,但對于 Git 的內部作業至關重要,還包括先前提交或父提交的哈希 ID 串列。
通常每個提交中的父級串列只有一個元素長,這為我們提供了一個簡單的線性向后看的提交鏈:
... <-F <-G <-H
這里H代表鏈中最后一次提交的實際哈希 ID 。CommitH存盤所有檔案的快照(截至某人制作時的狀態H)和一些元資料。中的元資料H保存了H的父提交的哈希 ID G,其中存盤了快照和一些元資料;用于G存盤 的哈希 ID的元資料F,其中存盤快照和元資料;以此類推,永遠——或者至少,直到我們回到第一個提交,它不能有父級,所以沒有:
A--B--C--D--E--F--G--H <-- latest
我們說 commitH向后指向G,向后指向F,依此類推。CommitA是第一個提交,它不指向任何地方,因此可以git log停止。
但是,要找到 H,我們必須告訴 Git 它的哈希 ID。為了避免自己記住哈希 ID,我們讓Git將此哈希 ID 保存在一個名稱中,例如分支名稱,latest. 然后該名稱指向H,這讓我們開始。
1我們可以通過鴿巢原理證明這實際上是行不通的。最終它會失敗。哈希 ID 的大小決定了失敗多快成為明顯的概率;通過把它做得足夠大,我們將失敗推到了我們不在乎的足夠遠的未來,因為從凱恩斯的長期來看,我們都死了。??
現在我們可以看到分支名稱是如何作業的
假設我們有一系列以 結尾的提交H,加上一個分支名稱,例如main:
...--G--H <-- main
We now add a second name, also pointing to H, so that all the commits are now on two branches:
...--G--H <-- dev, main
We need a way to pick out which name we're actually using. To do that, we'll have Git attach the special name HEAD to one of the branch names:
...--G--H <-- dev, main (HEAD)
This means we're "on" main, having done a git checkout main or git switch main, or having started out on main. Meanwhile we're using commit H. If we'd like to use the name dev instead, we run:
git switch dev
and get:
...--G--H <-- dev (HEAD), main
We're still using commit H, but we're using it through the name dev now.
A brief aside on Git's index / staging-area and your working tree
All the files in any Git commit snapshot are immutable. But we want to be able to mutate files: we can't get any actual new work done if we can't change the files. Git solves this problem like most version control systems: when we check out some commit, Git copies the files out of the commit into a work area. This work area is our working tree or work-tree.
It's important to realize that these files are not in Git. They came out of Git, but inside Git, they are in a special, read-only, compressed (sometimes highly compressed) and de-duplicated form, that only Git itself can read and literally nothing can write. So Git copies them out, and the copies are not in Git. The copies are instead ordinary everyday files, that every program can read and write in the usual way.
When programs do this, Git does not know that they are doing this.2 That's part of why you have to tell Git—with git add—that some file is updated.
Other version control systems have, historically, just scanned for changes. That is, you run their equivalent of checkout and they check out some commit or file. Then you run their equivalent of checkin / commit, and they scan everything, and you go out to lunch because this step will take at least 5 minutes and perhaps an hour or more. Git doesn't do this: instead, Git keeps an extra copy of every file, but in the compressed-and-de-duplicated form. Since these extra copies just came out of a commit, they are by definition duplicates, and therefore take no space.3 This makes up most of what Git calls its index or staging area.
When you run git add on some file, you're really telling Git: Read the working tree copy, and compress it into the internal de-duplicated form. If that turns out to be a duplicate, de-duplicate it now, so that it's prepared for the next commit. Otherwise prepare it for the next commit now. Either way, after git add, the index / staging-area copy now matches the working-tree copy, and is "staged for commit". If it matches the already-committed copy, Git doesn't say anything about it when you run git status. If not, git status says staged for commit. But in fact every file in Git's index is staged for commit: that's why this is the staging area. If Git said updated in proposed next commit, that might be better, but instead Git just says staged for commit.
2For efficiency, it's sometimes nice to use an OS's file-monitoring facilities, and Git has some primitive ability to do this on some OSes. But for the most part Git still isn't aware of this. Git has a different efficiency trick up its sleeves (if Git can be said to have sleeves).
3These index entries still take space to record their names and a bunch of related data, on the rough order of about 100 bytes per file.
Making new commits
Let's say we are in this state:
...--G--H <-- dev (HEAD), main
That is, we're on branch dev and using commit H. Meanwhile we've updated some files and run git add on them, so that the staged-for-commit copy doesn't match the copy in commit H. We now run git commit, and Git executes the following steps, in some order:
- Git collects any extra metadata it needs, such as our name and email address and the current date-and-time, and a log message.
- Git resolves the current commit to a raw hash ID (that of
H) to put in as the list of parent commits. - Git freezes for all time the snapshot as it appears in the index.
- Git combines all of these into a new commit, which gets a new unique hash ID; we'll call that
I. Note that new commitIpoints back to existing commitH. - Here's the tricky part: Git writes the new commit's hash ID into the current branch name.
So now we have:
I <-- dev (HEAD)
/
...--G--H <-- main
Note that git branch did not create a branch; git commit created the branch. At least, that's what happened as long as "the branch" means the fact that commit I, now exclusively on dev, "branches off" from main.
As we make more commits, they add on to I:
I--J <-- dev (HEAD)
/
...--G--H <-- main
until we git switch back to main:
I--J <-- dev
/
...--G--H <-- main (HEAD)
When we do switch commits, Git removes, from the working tree (and its index / staging-area), the files from commit J, and puts in the files from commit H instead. There's a bunch more trickiness here, but we'll ignore that.
If we create a third name and switch to that, and add two more commits, we get this situation:
I--J <-- dev
/
...--G--H <-- main
\
K--L <-- feature (HEAD)
It's important to realize two things here:
- Commits up through and including
Hare on all branches. - The name
mainis no longer needed in some sense: its purpose is to locate commitH. It still serves this purpose, but so do commitsJandL. By starting atdev(J) and working backwards, we will reach—and hence find—commitH. The same holds for commitL. However, we do need the namesdevandfeaturebecause those names are the only ways to find commitsI-JandK-Lrespectively.4
4If you goof this up—which is easy to do in Git—Git provides numerous ways to find the commits again, for a while. Eventually those "recover from mistake" entries, called reflogs, will expire. In what is probably a mistake, that has not been corrected in 15 years, deleting a branch name deletes the branch's reflog, so one should be at least somewhat cautious about branch-name deletion. If Git kept these reflogs, and there's work going on that might lead to this, you could "un-delete" a branch name.
True merges
Once we have a branch-y structure of commits—a commit graph with a branch in it—like this one:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
we often find it interesting and useful to use git merge. What git merge does with these, expressed as a high-level goal, is to combine work. "Work", in this case, is defined in terms of changes. Git doesn't store changes: Git stores commits. So to get changes, Git has to compare commits.
We already see this every day with git show or git log -p. When we use these commands, Git finds a commit and uses that commit's metadata to find the commit's parent commit:
...--o--o--P--C--o--...
To "show" commit C, Git finds its parent P, extracts both snapshots, and compares them. For every file that is the same, Git says nothing, and for every file that is different, Git figures out a recipe that will change the copy of that file in P to match the copy in C and produces that recipe.
If work is changes, and if we have:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
then it's intuitively obvious5 that if we compare the snapshot in H to that in J, we'll find out what work happened on br1. If we compare the snapshot in H to that in L, we'll find out what work happened on br2. Moreover, this produces two change recipes, as it were, that if applied to H, produce the snapshots in J and L respectively. If we combine the two recipes, we'll combine the work.
That is, suppose one recipe says to modify some file, and the other doesn't mention the file at all. The combination is to take the change. If both recipes say to change a shared file, we simply combine both changes: as long as they're to different regions of the file, we can probably do that. We'll skip right over the entire mechanism here and just assume that Git can combine changes and do so correctly.6 Git applies the combined changes to the common-starting-point snapshot, from H, and makes a new merge commit M:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
Commit M has a snapshot as usual: the snapshot is that built by applying the combined changes to the snapshot from H. Commit M has metadata as usual: you are the author-and-committer, its date-and-time is "now", and its default log message is the rather useless7 merge branch br2 into br1. The only thing that is different and special about M is that instead of one parent J, it has two: J and L. So when git log goes looking at what commits are "on" branch br1, Git will follow both links, and commits L and `K will be on the branch now, even though they were not, a moment ago.
If we don't ever need to find commits K-L quickly any more, we can now delete the name br2:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L
We can still find commit L by stepping back to the second parent of M, and from there we can find K. So we might delete the name br1. If we don't, we get the problem you wrote the post about in the first place.
5Mathematicians use this phrase to mean I don't want to prove it, and if I put it this way, you'll be too embarrassed to ask me to do that. ??
6As dumb as Git is—it has no knowledge of the contents of the files; it just applies simple line-by-line text rules here—this actually works surprisingly often. But this is less true for XML or JSON data; don't let Git combine XML or other structured text without careful inspection or testing, or both.
7This is not always completely useless, but any auto-generated text is rarely going to be as good as something someone actually thinks about. Most people don't normally write good merge messages, though; you can derive useful data by looking at the two parent chains.
Things that are not merges
Suppose that instead of the above branch-y diagram, we have the rather simpler:
I--J <-- dev
/
...--G--H <-- main (HEAD)
Suppose we now run git merge dev to combine work done on main vs work done on dev. The "work" we did on main will be: whatever is in commit H as compared to the files in commit H. But the files in commit H will, by definition, match the files in commit H. So there's no work done on main that isn't already on main. To that, we want to add the work done on dev, which is what we'll see as a recipe if we diff H vs J.
Git could do this as a regular merge:
I--J <-- dev
/ \
...--G--H------M <-- main (HEAD)
but if Git did this with the standard merge code, the snapshot in M would exactly match the snapshot in J. Commit M is in some sense not required. We do need it if we want to know that some feature was merged, but we don't need it if we just want to keep track of the commits and all the work.
By default, Git doesn't bother doing a full merge here. Instead, git merge dev just does a git checkout or git switch to commit J, while dragging the branch name forward, like this:
I--J <-- dev, main (HEAD)
/
...--G--H
and then there is no reason not to just draw everything on one line:
...--G--H--I--J <-- dev, main (HEAD)
We can now safely delete the name dev, as before, leaving no trace of the merge action. If we don't, though, and make more commits on main or otherwise advance the name main, we get:
...--G--H--I--J <-- dev
\
K <-- main (HEAD)
just as br2 will linger behind br1 after a true merge.
Now we can understand git branch --merged and git branch --no-merged
These commands needsone input: a commit. We pick some commit, like J or K or H or whatever. It then looks at all branch names, or with -r, all remote-tracking names (which I'll cover in a moment). For each such name:
- the name selects some commit;
- is that commit "ahead of" or "behind" the commit we picked?
Note that can be both, as is the case with:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
Here, commit L, found via name br2, is behind br1 or commit J because commits I and J are only on br1. But it's also ahead of br1 because commits K and L are only on br2. With:
...--o--P--C--o--...
commit P is one step behind C and C is one step ahead of P, and there are no complications, but when there is a "branch-y" graph structure, there are these complications.
What --no-merged does is look for any names that find any commits that are "ahead of" the selected commit. So if we select commit H, then git branch --no-merged will show us both names br1 and br2, as both names are ahead of H. But if we select commit J, git branch --no-merged will show us only the name br2, because br1 selects J, which is not ahead of J.
What --merged does is similar, except that it shows us any names where the name selects a commit that is not ahead of the one we pick. Let's use this diagram yet again, but add the name main pointing to H, and switch to main:
I--J <-- br1
/
...--G--H <-- main (HEAD)
\
K--L <-- br2
The git branch --merged command will, if we pick main / HEAD as the commit, show us only main, because br1 and br2 are both ahead of commit H. Note that --merged counts an "even" branch as merged, and since main selects H, git branch --merged main prints main.
If we pick commit J, though, it will show us the names main and br1, because both of those names pick a commit that is not ahead of commit J. Or, if we pick commit L, it will show us names main and br2.
Remote-tracking names
Git is not just a version control system. It's a distributed (and actually more important here, replicated) version control system. We make copies of repositories with git clone. Each repository contains commits, but each repository also contains these branch names that help us find commits.
When we clone a repository, we copy all of its commits8 and none of its branch names. That is, the names in the repository we copy are private to that particular repository. We can, however, see them while our Git, working on our repository, is hooked up to their Git software that's reading their repository. So our Git takes their <name, hash-ID> pairs and stores them in our repository too, but first it changes the names.
We give their repository a name. The standard name we use for "the" other repository (when there's only one such) is origin. That is, we run:
git clone -o origin <url>
and our Git saves the URL under the name origin. If we don't use -o, the default name is origin anyway, so we mostly don't use -o. In any case, this name—which is almost always origin, though you can change it—is something Git calls a remote. It's mainly a short name by which we can refer to their repository, instead of typing out the URL repeatedly.9 I like to refer to this as "their Git": their Git software answers at this URL, which connects their Git software to their repository, or "their Git".
To build the names it will use to save their branch names, our Git sticks our remote name in front of their Git's branch names: their main becomes our origin/main, for instance, and their dev becomes our origin/dev. So after git clone, we have a repository with all the commits, and with all their branch names changed into these funny origin-prefixed names. These names correspond to their branch names, but they literally are not branch names: if you git checkout origin/dev your Git tells you that it's gone into "detached HEAD" mode.
Having done all this copying, the last step of git clone is that our Git will create one branch name. We pick the branch name with -b: git clone -b dev url for instance. If we don't pick a name with -b, our Git will ask their Git what they recommend, which is usually master or main, and then our Git creates that name.
What all this means is that we end up with a repository with all their commits (but see footnote 8) and one branch. Their branches have become our remote-tracking names. Our one branch, that git clone created as its last step, points to the same commit as one of their branch names, and that's the branch we have checked out right now.
To update our remote-tracking names, we run git fetch:
git fetch origin
This tells our Git to look up the name origin, convert it to a URL, contact the Git software there, and have them list out their branch names and hash IDs. Our Git can immediately tell, from the hash IDs, whether we have all of their commits, or need to get some commits from them. If we need commits, our Git converses with their Git to make a more complete list, then gets their new commits and stuffs those into our repository: because these are the same commits, they have the same hash IDs. Now we have all their commits, plus any commits we had before that they didn't have.
Having obtained from them any new commits they have that we need, our Git now updates our remote-tracking names to remember which commits their branch names remember. And then we're done fetching and our Git disconnects from their Git.
(If we want to send them commits that we have that they don't, we use git push. This is almost a mirror image of git fetch, with one really huge exception: they don't have any remote-tracking names for us. We ask them to create or set one of their branch names, after we send them new commits. But we'll skip over all of this here.)
8This is a bit of an overstatement: we copy the reachable commits, and we can deliberately limit how many of those we copy too. But the default is to copy all reachable commits, and people generally don't worry about nominally-removed, still-findable-by-reflog commits, so saying "all commits" is a good way to think of it, as long as you remember that there's a footnote.
9In primeval Git, you really did have to type out the URL each time. This was pretty error-prone and the Git folks invented a bunch of different hacks to get around it. The one that really stuck, in the end, was this idea of a remote, origin.
Conclusion
The git branch command is the user-facing (or porcelain) command that iterates over branch names, or things that look like branch names such as remote-tracking names. It also lets us create and delete branch names, though that's not what we're concerned with here.
Using --merged or --no-merged, we can pick out one commit in our repository, and ask which names—branch and/or remote-tracking names—in our repository point to specific commits that are either not ahead of (--merged) or are ahead of (--no-merged) the one commit we picked out. Because of the nature of the commit graph and the way branch names work, that usually gets us what we want here.
(請注意,我們上面沒有介紹的所謂的squash merge根本不是合并,因此如果有人一直在使用 squash merge,這將不起作用。)
uj5u.com熱心網友回復:
除了toreks 驚人的答案,我偶然發現了這個- 找出“過時”的分支真的分解為
git branch -r | xargs -t -n 1 git branch -r --contains
我想出了一個 PowerShell 片段,git branch --merged它可以集成到基于豪華的環境中:
[CmdletBinding()]
param (
# path to local git repository
[Parameter(Mandatory)]
[string]
$Path,
# the branches (regex) in this list will be ignored
[Parameter(Mandatory = $False)]
[string[]]
$IgnoreBranches = @('main', 'master', 'dev', 'develop', '^. _Maintenance$')
)
$ErrorActionPreference = 'Stop'
function Exec {
[CmdletBinding()]
param (
[Parameter(Mandatory, ValueFromPipeline)]
[scriptblock]
$ScriptBlock
)
$LASTEXITCODE = 0
try {
& $ScriptBlock
$theEc = $LASTEXITCODE
}
finally {
if ($theEc -ne 0) {
Write-Error "expected 0 exit code, got $theEc"
Write-Error $ScriptBlock.ToString()
throw "command exited with $theEc"
}
}
}
Push-Location $Path
try {
# ensure we're not analyzing a shallow checkout, prune branches deleted on remote
Exec { git fetch --prune }
# get list of all branches that exist on remote
$remoteBranches = (Exec { git branch -r }).Trim()
# map used to store wich branches are fully contained in other branches (b -> fully contained in (a,c,d))
# string -> string[]
$fullyContainedBranches = @{}
# foreach branch, figure out what other branches are "fully contained" -> candidates for deletion
foreach ($b in $remoteBranches) {
$b = $b.Split(' ')[0]
Write-Verbose " checking fully integrated branches of '$b'"
$fullyContained = Exec { git branch -r --merged $b }
if (-not $fullyContained) {
Write-Verbose " '$b' is already gone for good!"
$fullyContainedBranches[$b] = @()
continue
}
foreach ($f in $fullyContained) {
$f = $f.Replace('* ', '').Trim()
Write-Verbose " -> '$f' is in '$b'"
if (-not $fullyContainedBranches[$f]) {
$fullyContainedBranches[$f] = @()
}
$fullyContainedBranches[$f] = $b
}
}
$fullyContainedBranches.Keys | Foreach-Object {
$branchName = $_
if ($IgnoreBranches) {
if (($IgnoreBranches | ForEach-Object { $branchName -match $_ }) -contains $true) {
Write-Verbose "ignoring $branchName"
return
}
}
# put output objects to pipeline
@{
branch = $branchName
contained_in = $fullyContainedBranches[$branchName]
}
}
}
finally {
Pop-Location
}
轉載請註明出處,本文鏈接:https://www.uj5u.com/qukuanlian/409102.html
標籤:
