如何列出 git 中包含未合并提交的所有 'active' 分支

how to list all 'active' branches in git containing unmerged commits

我正在努力理解几个非常大的存储库的历史,这些存储库有数百个(旧的)分支,这些分支从未被删除(即使这些分支中的大部分工作是 'done' ).

我正在尝试找到一种方法来生成分支列表

如果我的假设是正确的,这应该 return 包含 unmerged/active 代码的分支列表 - 其他所有内容都可以安全删除。

一个不错的噱头是通过 git log --graph 将其可视化 - 仅显示 'current working tree',仅返回到所有 'currently active branches' 中存在的第一个提交。

非常感谢suggestions/help!

TL;DR:git branch --no-merged HEAD 可能是您想要的答案。您可能想要添加 -r-a,或者使用 HEAD 以外的内容。您可能想要 运行 这个(调整后的)命令多次,每个分支名称一次(尽管在这种情况下有一些方法可以更有效地执行此操作,在 this possible cost)。

重要的是要意识到 Git 实际上并没有合并 分支 。或者更准确地说,我们必须首先定义 branch 的含义(参见 What exactly do we mean by "branch"?);根据我们使用的定义,Git 没有 分支,或者没有 merge 分支,或者合并分支但是然后他们有时 un-merge 后来;或者还有其他可能性,这取决于 you 所说的“分支”是什么意思。 Git 做的 合并——可能对你的问题有用的方式,即——是提交。分支名称可以帮助您 Git find 提交,这些提交在 commit graph 中独立存在,这就是您将如何使用上面的答案。

A Git repository 实际上主要是 commits 的集合。 Git 与文件无关——尽管提交 包含 文件——与分支无关,或者至少与分支名称无关(well-defined,与“分支”不同"),尽管分支名称可以帮助我们找到提交。这实际上只是关于提交,所以你需要能够可视化提交:

A nice gimmick would be to to visualize this via git log --graph

您可以这样做,但是:

only displaying the 'current working tree', going back only to the first commit that's present in all of the 'currently active branches'.

工作树实际上Git中,考虑到分支 =648=] 是首先定义的,再加上单词 active 完全是 un 定义的事实,我们可能永远不会知道“当前活跃的分支机构”甚至意味着什么。所以我们不可能那样做。

在Git中的是提交。提交:

  • 被编号:每个都有一个唯一的编号,或哈希ID,表示在hexadecimal中。一旦某个哈希 ID 分配给某个特定的提交,就意味着 那个 提交,永远,在每个Git存储库。换句话说,这些提交哈希 ID 是普遍唯一的。1 Git 遵循这个原则做了很多事情:例如,我们将两个 Git 存储库挂钩到彼此,使用 git fetchgit push,他们只交换原始哈希 ID,并立即知道哪个 提交 (因此文件)另一个 Git 需要得到。

  • 是不可变的:任何提交的任何部分都不能改变。 (这适用于所有 Git 的内部 objects,所有这些都使用 UUID 散列方案。散列仅在 objects 不能更改时才有效。)

  • 存储两件事:所有文件的快照(采用特殊的内部 read-only de-duplicated 格式)和一些元数据。元数据包括诸如谁做出提交以及何时提交之类的内容,而且对于 Git 的内部工作至关重要,previous 或 [=447= 的哈希 ID 列表]parent,提交。

通常每个提交中的 parent 列表只有一个元素长,这给了我们一个简单的线性 backwards-looking 提交链:

... <-F <-G <-H

这里的 H 代表链中 last 提交的实际哈希 ID。提交 H 存储所有文件的快照(截至他们在有人制作 H 时的状态)和一些元数据。 H 中的元数据包含 H 的 parent 提交 G 的哈希 ID,它存储快照和一些元数据; G 的元数据存储 F 的哈希 ID,后者存储快照和元数据;依此类推,直到永远——或者至少,直到我们回到有史以来的第一次提交,它不能有 parent,所以就没有:

A--B--C--D--E--F--G--H   <-- latest

我们说提交 H 向后指向 G,后者向后指向 F,依此类推。提交 A,作为第一个提交,不指向任何地方,因此允许 git log 停止。

为了找到H,不过,我们必须告诉Git它的hash ID。为了避免自己必须记住哈希 ID,我们 Git 将此哈希 ID 保存在名称中,例如分支名称,latest。然后该名称指向 H,让我们开始。


1我们可以通过pigeonhole principle证明这实际上行不通。最终它 失败。哈希 ID 的大小决定了失败在多长时间内成为一个明显的概率;通过让它足够大,我们将失败推到我们不关心的未来,因为在凯恩斯的长期 运行 中,我们都死了。


现在我们可以看到分支名称是如何工作的

假设我们有一系列以 H 结尾的提交加上一个分支名称像 main:

...--G--H   <-- main

我们现在添加一个 second 名称,也指向 H,这样所有提交现在都在 两个分支:

...--G--H   <-- dev, main

我们需要一种方法来找出我们实际上使用名称。为此,我们将 Git 将特殊名称 HEAD 附加到分支名称之一:

...--G--H   <-- dev, main (HEAD)

这意味着我们“在”main,已经完成 git checkout maingit switch main,或者已经开始 main。与此同时,我们 使用 提交 H。如果我们想改用名称 dev,我们 运行:

git switch dev

并得到:

...--G--H   <-- dev (HEAD), main

我们仍在使用 commit H,但我们通过 name dev现在

关于 Git 的索引/staging-area 和你的工作树的简要说明

任何 Git 提交快照中的所有文件都是不可变的。但是我们希望能够改变文件:如果我们不能更改 文件,我们就无法完成任何实际的新工作。 Git 像大多数版本控制系统一样解决了这个问题:当我们 签出 一些提交时,Git 将文件从提交中复制出来进入工作区。这个工作区就是我们的工作树或者work-tree.

重要的是要认识到这些文件 不在 Git 中。它们来自 Git 的 ,但在 Git 内部,它们处于特殊的 read-only 压缩(有时高度压缩)和 de-duplicated 形式,只有 Git 本身可以读取,实际上什么都不能写。所以Git把它们复制出来,复制的不在Git里。这些副本是普通的日常文件,每个程序都可以以通常的方式读写。

当程序执行此操作时,Git 不知道它们正在执行此操作。2 这就是为什么你必须告诉 Git——用 git add——某个文件已更新。

历史上,其他版本控制系统只是扫描更改。也就是说,你 运行 他们相当于检出,他们检出一些提交或文件。然后你 运行 他们相当于签入/提交,他们扫描所有内容,然后你出去吃午饭,因为这一步至少需要 5 分钟,可能需要一个小时或更长时间。 Git 不这样做:相反,Git 保留每个文件 的额外副本,但以压缩和de-duplicated 的形式。由于这些额外的副本刚刚来自提交,根据定义它们是重复的,因此不带 space.3 这构成了 Git 调用的大部分内容它的 indexstaging area.

当你 运行 git add 处理某些文件时,你实际上是在告诉 Git: 读取工作树副本,并将其压缩到内部 de-duplicated表格。如果结果是重复的,de-duplicate 现在它,以便为下一次提交做好准备。否则现在就为下一次提交做好准备。 无论哪种方式, git add 之后,索引 / staging-area 副本现在与 working-tree 复制,并“暂存提交”。如果它与 already-committed 副本相匹配,那么当您 运行 git status 时,Git 不会 任何有关它的信息。如果不是,git status staged for commit。但实际上 Git 的索引 中的每个文件都是 准备提交的:这就是为什么这是 暂存区 的原因。如果 Git 说 updated in proposed next commit,那可能会更好,但是 Git 只是说 staged for commit


2为了提高效率,有时使用 OS 的 file-monitoring 设施很好,而且 Git 有一些原始的能力在某些 OSes 上执行此操作。但在大多数情况下 Git 仍然没有意识到这一点。 Git有一个不同的效率技巧(如果Git可以说是有袖子的话)。

3这些索引条目还带space记录他们的名字和一堆相关数据,粗略的每个文件大约 100 个字节的顺序。


进行新提交

假设我们处于这种状态:

...--G--H   <-- dev (HEAD), main

也就是说,我们在分支 dev 上并使用提交 H。同时我们已经更新了一些文件和 运行 git add,所以 staged-for-commit 副本与 in commit H。我们现在 运行 git commit 和 Git 按某种顺序执行以下步骤:

  • Git 收集它需要的任何额外元数据,例如我们的姓名和电子邮件地址以及当前 date-and-time 和日志消息。
  • Git 将当前提交解析为原始哈希 ID(H 的哈希 ID)以作为 parent 提交列表放入。
  • Git 快照出现在索引中时会一直冻结。
  • Git 将所有这些合并到一个新的提交中,该提交将获得一个新的唯一哈希 ID;我们称之为 I。请注意,新提交I 指向现有提交 H.
  • 这是棘手的部分:Git 将新提交的哈希 ID 写入当前 分支名称

所以现在我们有:

          I   <-- dev (HEAD)
         /
...--G--H   <-- main

注意git branch没有创建分支; git commit 创建了分支。 至少,只要“分支”意味着提交 I,现在完全在 dev,“分支关闭”,就会发生这种情况" 来自 main.

随着我们进行更多的提交,它们会添加到 I:

          I--J   <-- dev (HEAD)
         /
...--G--H   <-- main

直到我们 git switch 回到 main:

          I--J   <-- dev
         /
...--G--H   <-- main (HEAD)

当我们切换提交时,Git从工作树(及其索引/staging-area)中删除提交J,并改为放入来自提交 H 的文件。这里还有很多技巧,但我们会忽略它。

如果我们创建第三个名称并切换到该名称,再添加两次提交,我们会遇到这种情况:

          I--J   <-- dev
         /
...--G--H   <-- main
         \
          K--L   <-- feature (HEAD)

这里有两点很重要:

  1. 通过并包括 H 的提交在 所有分支上
  2. 名称main在某种意义上不再需要:它的目的是定位提交H。它仍然服务于此目的,但提交 JL 也是如此。通过从 dev (J) 开始并向后工作,我们将到达并因此找到提交 H。这同样适用于提交 L。但是,我们确实需要名称devfeature,因为这些名称是查找提交的方法I-JK-L 分别。4

4如果你搞砸了——这在 Git 中很容易做到——Git 提供了多种再次查找提交的方法,一段时间。最终,那些名为 reflogs 的“从错误中恢复”条目将过期。删除分支名称会删除分支的引用日志,这可能是一个错误,在 15 年多的时间里都没有得到纠正,因此至少应该对 branch-name 删除保持谨慎。如果 Git 保留这些 reflogs,并且正在进行的工作可能会导致这种情况,您可以“un-delete”一个分支名称。


真正的合并

一旦我们有了一个 branch-y 提交结构——其中有一个分支的提交图——就像这样:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

我们经常发现使用 git merge 很有趣也很有用。 git merge 对这些所做的,表示为 high-level 目标,是 合并工作 。在这种情况下,“工作”是根据 变化 来定义的。 Git 不存储更改:Git 存储提交。所以要获得更改,Git必须比较提交

我们每天都在 git showgit log -p 中看到这一点。当我们使用这些命令时,Git 找到一个提交并使用该提交的元数据找到该提交的 parent 提交:

...--o--o--P--C--o--...

为了“显示”提交C,Git找到它的parentP,提取两个快照,并进行比较他们。对于每个相同的文件,Git 什么都不说,对于每个不同的文件,Git 计算出一个配方,该配方将更改 P 中该文件的副本以匹配复制 C 并生成该食谱。

如果 workchanges,并且如果我们有:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

然后很明显5 如果我们将 H 中的快照与 J 中的快照进行比较,我们将找出发生了什么工作br1。如果我们将 H 中的快照与 L 中的快照进行比较,我们将了解 br2 上发生了什么工作。此外,这会产生两个 更改配方 ,如果应用于 H,则分别在 JL 中生成快照。如果我们合并这两个食谱,我们将合并工作

也就是说,假设一个食谱说要修改某个文件,而另一个根本没有提到该文件。组合是采取变化。如果两个食谱都说要更改 shared 文件,我们只需将两个更改组合起来:只要它们针对文件的不同 regions,我们可能可以做到这一点。我们将在这里跳过整个机制,并假设 Git 可以 组合更改并正确执行。6 Git 将组合更改应用到 H 的 common-starting-point 快照,并进行新的 merge commit M:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

提交 M 像往常一样有一个快照:快照是通过将组合更改应用于 H 的快照而构建的。提交 M 像往常一样有元数据:你是 author-and-committer,它的 date-and-time 是“现在”,它的默认日志消息是相当无用的7 merge branch br2 into br1only 关于 M 的不同和特殊之处在于,而不是 onparentJ,它有两个:JL。因此,当 git log 查看什么提交“在”分支 br1 上时,Git 将遵循 两个链接 ,并提交 L `K 现在会在树枝上,即使他们刚才不在树枝上。

如果我们不再需要快速找到提交 K-L,我们现在可以 删除 名称 br2:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L

我们仍然可以通过回到 Msecond parent 找到提交 L,从那里我们可以找到 K。所以我们可能会删除名称 br1如果我们不这样做,我们就会得到您首先写的 post 的问题。


5数学家用这句话来表示我不想证明,这样说你就太尴尬了让我这样做。

6和Git一样愚蠢——它不知道文件的内容;它只是在这里应用简单的 line-by-line 文本规则——这实际上经常出奇地有效。但这对于 XML 或 JSON 数据就不那么正确了;不要让 Git 合并 XML 或其他未经仔细检查或测试的结构化文本。

7这并不总是完全没用,但任何auto-generated文字很少能像某些东西一样好还真有人想过。不过,大多数人通常不会编写好的合并消息;您可以通过查看两个 parent 链来得出有用的数据。


不合并的东西

假设我们有一个更简单的图表而不是上面的 branch-y 图:

          I--J   <-- dev
         /
...--G--H   <-- main (HEAD)

假设我们现在 运行 git merge dev 将在 main 上完成的工作与在 dev 上完成的工作相结合。我们在 main 上所做的“工作”将是:与提交 H 中的文件相比,提交 H 中的任何内容。但是提交 H 中的文件 根据定义 将匹配提交 H 中的文件。因此,main 上尚未完成的工作尚未在 main 上完成。为此,我们想要 添加 dev 上完成的工作,如果我们比较 H 与 [=97=,这就是我们将看到的配方].

Git 可以作为常规合并来执行此操作:

          I--J   <-- dev
         /    \
...--G--H------M   <-- main (HEAD)

但如果 Git 使用标准合并代码执行此操作,则 M 中的 快照 将与 J 中的快照完全匹配。提交 M 在某种意义上不是 必需的 。我们确实需要它,如果我们想知道某些特征被合并,但我们不需要如果我们只想跟踪提交和所有 work.

就需要它

默认情况下,Git 不会在这里进行完全合并。相反,git merge dev 只是执行 git checkoutgit switch 来提交 J,同时 向前拖动分支名称 ,像这样:

          I--J   <-- dev, main (HEAD)
         /
...--G--H

然后没有理由不把所有东西画在一条线上:

...--G--H--I--J   <-- dev, main (HEAD)

我们现在可以像以前一样安全地删除名称 dev,不留下任何合并操作的痕迹。但是,如果我们 ,并在 main 上进行更多提交或以其他方式推进名称 main,我们将得到:

...--G--H--I--J   <-- dev
               \
                K   <-- main (HEAD)

正如 br2 会在真正合并后留在 br1 后面。

现在我们可以理解git branch --mergedgit branch --no-merged

这些命令需要一个输入:提交。我们选择一些提交,比如 JKH 或其他。然后它查看所有 分支名称 ,或 -r,所有 remote-tracking 名称(我将在片刻)。对于每个这样的名称:

  • 名称select一些提交;
  • 该提交是在我们选择的提交“之前”还是“之后”?

注意可以两者都,如:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

在这里,提交 L,通过名称 br2 找到,落后于 br1 或提交 J 因为提交 IJ 仅在 br1 上。但它也 领先于 br1,因为提交 KL 仅在 br2 上。随着:

...--o--P--C--o--...

commit PC 落后一步,CP 领先一步,并且没有并发症,但是当出现“branch-y”图结构,有这些并发症。

--no-merged 所做的是查找任何 names 以找到任何 commits “领先于”selected 提交。因此,如果我们 select 提交 H,那么 git branch --no-merged 将向我们显示名称 br1br2,因为这两个名称都在 H 之前。但是如果我们 select 提交 Jgit branch --no-merged 将只显示名称 br2,因为 br1 selects J,这并不领先于 J.

--merged 所做的是相似的,除了它向我们显示任何名称,其中名称 select 是 不在 我们之前提交的提交挑选。让我们再次使用此图,但添加指向 H 的名称 main,并切换到 main:

          I--J   <-- br1
         /
...--G--H   <-- main (HEAD)
         \
          K--L   <-- br2

如果我们选择 main / HEAD 作为提交,git branch --merged 命令将只显示 main,因为 br1br2 领先于 提交 H。请注意,--merged 将“偶数”分支计为合并,并且由于 main selects Hgit branch --merged main 打印 main.

如果我们选择提交 J,但是,它将向我们显示名称 mainbr1,因为 那些 名称选择一个不在提交 J 之前的提交。或者,如果我们选择提交 L,它会显示名称 mainbr2.

Remote-tracking 名字

Git 不仅仅是一个版本控制系统。它是一个 分布式 (实际上更重要的是,复制的)版本控制系统。我们使用 git clone 制作存储库的副本。每个存储库都包含提交,但每个存储库 包含这些分支名称,可帮助我们 找到 提交。

当我们克隆一个存储库时,我们复制它的所有提交8none它的分支名称。也就是说,我们复制的存储库中的名称 对该特定存储库 是私有的。但是,我们可以 看到 他们,而我们的 Git 在我们的存储库上工作,连接到他们的 Git 软件,该软件正在读取 他们的 资料库。所以我们的 Git 将它们的 对存储在 我们的 存储库中,但首先它 更改名称 .

我们为他们的存储库命名。我们用于“那个”另一个存储库(当只有一个这样的存储库时)的标准名称是 origin。即我们运行:

git clone -o origin <url>

而我们的 Git 将 URL 保存在名称 origin 下。如果我们不使用-o,无论如何默认名称都是origin,所以我们大多不使用-o。在任何情况下,这个名称——几乎总是 origin,尽管你可以更改它——是 Git 称为 远程 的东西。它主要是一个简短的名称,我们可以通过它来引用他们的存储库,而不是重复输入 URL。9 我喜欢将其称为“他们的 Git”:他们的 Git 软件在这个 URL 上回答,它将他们的 Git 软件连接到他们的存储库,或“他们的 Git”。

要构建它将用于保存 他们的 分支名称的名称,我们的 Git 将我们的远程名称放在前面他们 Git 的分支名称:例如,他们的 main 变成了我们的 origin/main,而他们的 dev 变成了我们的 origin/dev。所以在 git clone 之后,我们有一个包含所有提交的存储库,并且它们所有的 分支 名称都变成了这些有趣的 origin 前缀名称。这些名称对应于它们的分支名称,但它们实际上不是 branch 名称:如果您 git checkout origin/dev 您的 Git 告诉您它已进入“分离 HEAD”模式.

完成所有这些复制后,git clone 的最后一步是我们的 Git 将 create one 分店名称。我们选择带有 -b 的分支名称:例如 git clone -b dev <em>url</em>。如果我们取一个带-b的名字,我们的Git会问他们Git他们推荐什么,通常是mastermain,然后我们的 Git 创建该名称。

这意味着我们最终得到一个包含 所有提交(但请参阅脚注 8)和 一个分支 的存储库.他们的分支已经成为我们的remote-tracking名字。我们的一个分支,git clone 作为其最后一步创建,指向相同的 commit 作为他们的分支名称之一,这就是我们现在签出的分支。

更新我们的remote-tracking名字,我们运行git fetch:

git fetch origin

这告诉我们的 Git 查找名称 origin,将其转换为 URL,联系那里的 Git 软件,并让他们列出他们的名字分支名称和哈希 ID。我们的 Git 可以立即从哈希 ID 判断我们是否拥有他们的所有提交,或者是否需要从他们那里获得一些提交。如果我们需要提交,我们的 Git 会与他们的 Git 交谈以生成更完整的列表,然后获取他们的新提交并将其填充到我们的存储库中:因为这些 相同 提交,它们具有 相同的哈希 ID。现在我们有他们所有的提交,加上我们之前有但他们没有的任何提交。

从他们那里获得我们需要的任何新提交后,我们的 Git 现在更新我们的 remote-tracking 名称以记住哪些提交他们的 分支 名称记住。然后我们完成了获取,我们的 Git 与他们的 Git.

断开了连接

(如果我们想发送他们承诺我们有那个不要,我们用git push。这几乎是 git fetch 的镜像,有一个非常大的例外:他们没有任何 us 的 remote-tracking 名字。在我们向他们发送新提交后,我们要求他们创建或设置他们的 分支 名称之一。但我们将在这里跳过所有这些。)


8这有点夸大其词:我们复制了 reachable 提交,我们可以有意限制其中的数量抄也。但是默认的是复制所有可达的commit,大家一般不会担心nominally-removed、still-findable-by-reflog的commit,所以说“all commits”是个好主意怎么想,只要记得有脚注就可以了。

9在原始的Git中,你确实每次都必须输入URL。这很漂亮 error-prone 并且 Git 的人发明了一堆不同的 hack 来绕过它。最后,真正卡住的是 远程 origin.

的想法

结论

git branch命令是user-facing(或porcelain)命令,它遍历分支名称,或者看起来像分支名称的东西,例如remote-tracking名字。它还允许我们创建和删除分支名称,尽管这不是我们在这里关心的。

使用--merged--no-merged,我们可以在我们的存储库中挑选出一个提交,并询问哪些名称-分支and/or remote-tracking 名称——在我们的存储库中指向特定的提交,这些提交要么 不领先于 (--merged) 要么 领先于 (--no-merged) 我们挑选的一个提交。由于提交图的性质和分支名称的工作方式,这通常会让我们在这里得到我们想要的。

(请注意,我们上面没有涉及的 so-called squash 合并 根本不是合并,因此如果有人已经使用壁球合并。)

除了 I stumbled upon this - 弄清楚 'obsolete' 分支确实分解为

git branch -r | xargs -t -n 1 git branch -r --contains

我想出了一个包含 git branch --merged 的 PowerShell 片段,可以集成到基于豪华的环境中:

[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 ($f -eq $b) {
        continue
      }

      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
}