跳转到主要内容
该数据集包含 ClickHouse 仓库中的所有 提交 和变更。可使用 ClickHouse 随附分发的原生 git-import 工具生成。 生成的数据会为以下每个表提供一个 tsv 文件:
  • commits - 带统计信息的 提交。
  • file_changes - 每个 提交 中发生变更的文件,以及变更信息和统计信息。
  • line_changes - 每个 提交 中每个已变更文件里的每一处变更行,包含该行的完整信息以及该行上一次变更的信息。
截至 2022 年 11 月 8 日,每个 TSV 的大致大小和行数如下:
  • commits - 7.8M - 266,051 行
  • file_changes - 53M - 266,051 行
  • line_changes - 2.7G - 7,535,157 行

生成数据

这一步是可选的。我们免费提供这些数据——请参阅下载并插入数据
这大约需要 3 分钟 (截至 2022 年 11 月 8 日,在 MacBook Pro 2021 上完成 ClickHouse 仓库的操作) 才能完成。 可用选项的完整列表可通过该工具的内置帮助获取。
本帮助文档还提供了上述各表的 DDL,例如:
这些查询应适用于任何仓库。欢迎自行尝试,并反馈你的发现。 关于执行时间的一些参考 (截至 2022 年 11 月) :
  • Linux - ~/clickhouse git-import - 160 分钟

下载并插入数据

以下数据可用于复现一个可运行的环境。此外,也可以在 play.clickhouse.com 上获取此数据集——更多详情请参见查询 下方提供了以下仓库生成的文件: 要插入这些数据,请先执行以下查询来准备数据库:
使用 INSERT INTO SELECTs3 函数 导入数据。例如,下面将 ClickHouse 文件分别导入到对应的表中: commits
file_changes
line_changes

查询

该工具会在帮助输出中给出若干建议查询。除了回答这些查询之外,我们还补充回答了一些其他值得关注的问题。与工具中较为随意的排列顺序不同,下面这些查询大致按复杂度递增排序。 该数据集可在 play.clickhouse.comgit_clickhouse 数据库中获取。我们为所有查询都提供了指向该环境的链接,并按需调整数据库名称。请注意,由于数据采集时间不同,play 中的结果可能与此处展示的结果有所差异。

单个文件的历史

最简单的一类查询。这里我们查看 StorageReplicatedMergeTree.cpp 的所有提交信息。由于这些内容通常更值得关注,我们按时间倒序排列,优先显示最新的消息。 play
我们还可以查看行级变更,但不包括重命名;也就是说,如果文件此前使用的是其他名称,我们不会显示重命名事件之前的变更: play
请注意,这个查询还有一个更复杂的变体:它会在考虑重命名的情况下,查找文件的逐行提交历史

查找当前活跃文件

这对后续分析很重要,因为我们只想考虑 仓库 中当前的文件。我们将这一集合估算为那些未被重命名或删除 (随后又被重新添加/再次重命名) 的文件。 请注意,dbmslibstests/testflows/ 目录下文件在重命名期间,其 提交历史 似乎存在断裂。因此,我们也将这些目录排除在外。 play
请注意,这样就允许文件先被重命名,之后再改回原来的名称。首先,我们对因重命名而被删除的文件列表中的 old_path 进行聚合。然后,将其与每个 path 的最后一次操作取并集。最后,我们过滤出最终事件不是 Delete 的项。 play
请注意,我们在导入时跳过了多个目录,例如: --skip-paths 'generated\.cpp|^(contrib|docs?|website|libs/(libcityhash|liblz4|libdivide|libvectorclass|libdouble-conversion|libcpuid|libzstd|libfarmhash|libmetrohash|libpoco|libwidechar_width))/' 将此模式应用到 git list-files 时,会得到 18155。
因此,我们当前的方案只是对当前文件数量的估算 这里的差异是由几个因素导致的:
  • 重命名可能会与文件的其他修改同时发生。这些更改会在 file_changes 中作为单独的事件列出,但时间相同。argMax 函数无法区分这些事件——它会选择第一个值。插入的自然顺序 (这是判断正确顺序的唯一依据) 在经过 union 后不会被保留,因此可能会选中修改事件。例如,下面的 src/Functions/geometryFromColumn.h 文件在被重命名为 src/Functions/geometryConverters.h 之前经历了多次修改。我们当前的解决方案可能会将某个 Modify 事件识别为最新变更,导致 src/Functions/geometryFromColumn.h 被保留下来。
play
  • 提交历史异常——缺少删除事件。具体来源和原因尚待确认。
这些差异不应对我们的分析产生实质性影响。欢迎提供此查询的改进版本

列出修改最多的文件

仅统计当前文件时,我们将修改次数视为删除次数与新增次数之和。 play

提交通常发生在一周中的哪一天?

play
考虑到周五生产力略有下降,这也说得通。很高兴看到大家周末也在提交代码!非常感谢我们的贡献者!

子目录/文件的历史 - 行数、提交数和贡献者数量随时间的变化

这会产生一个很大的查询结果;如果不加过滤,既不适合展示,也不适合可视化。因此,在下面的示例中,我们支持按文件或子目录进行过滤。这里使用 toStartOfWeek 函数按周分组,请根据需要调整。 play
这些数据非常适合做可视化展示。下面我们使用 Superset。 按新增和删除的行数: 按提交和作者:

列出作者最多的文件

仅限当前文件。 play

仓库中历史最久的代码行

仅限当前文件。 play

历史记录最长的文件

仅统计当前文件。 play
我们的核心数据结构 Merge Tree 显然一直在不断演进,而且有着漫长的修改历史!

一个月内贡献者在文档与代码方面的分布

在数据捕获期间,由于 docs/ 文件夹的提交历史非常混乱,其中的更改已被过滤掉。因此,此查询的结果并不准确。 我们是否会在每个月的某些时间点编写更多文档,比如在发布日期前后?我们可以使用 countIf 函数计算一个简单的比率,并用 bar 函数将结果可视化。 play
也许在月底时会稍微多一点,但总体上分布仍然比较均匀。再次说明,由于在数据插入过程中应用了 docs 过滤器,因此这一结果并不可靠。

影响最广泛的作者

这里的“多样性”是指某位作者参与贡献过的不同文件数量。 play
让我们来看看,谁最近的提交最多样。我们不按日期限制,而是只看某位作者最近的 N 次提交 (这里用的是 3 次,你也可以自行修改) : play

某位作者最关注的文件

这里我们选择创始人 Alexey Milovidov,并将分析范围限定在当前文件上。 play
这很合理,因为 Alexey 一直负责维护 Change log。不过,如果我们用文件的基础名来识别他那些热门文件呢?这样既能兼容重命名,也更能聚焦于代码贡献。 play
这或许更能体现他的兴趣所在。

作者数量最少的最大文件

为此,我们首先需要找出最大的文件。如果根据提交历史为每个文件完整重建其内容来估算,成本会非常高! 为了进行估算,假设我们只考虑当前文件,可以将新增的行数相加,再减去删除的行数。然后,我们就能计算出文件长度与作者数量之间的比率。 play
文本字典可能不太现实,所以我们通过文件扩展名过滤器将范围缩小到仅代码! play
这里存在一定的新近偏差——较新的文件经历提交的机会更少。如果我们只看文件年龄至少 1 年的情况,会怎样呢? play

按时间、星期几、作者及特定子目录统计的提交和代码行分布

这里我们将其理解为按星期几统计新增和删除的代码行数。在本例中,我们重点关注 Functions 目录 play
按一天中的时段来看, play
考虑到我们的大部分开发团队都在阿姆斯特丹,这样的分布是合理的。bar 函数可以帮助我们直观展示这些分布: play

展示哪些作者倾向于重写他人代码的作者矩阵

sign = -1 表示代码被删除。我们排除了标点符号以及空行的插入。 play
Sankey 图表 (SuperSet) 可以很好地展示这一点。注意,我们将 LIMIT BY 增加到 3,以便找出每位作者对应的前 3 位“代码移除者”,从而让可视化结果更加丰富。 Alexey 显然很喜欢删除别人的代码。为了让代码删除情况的视图更加均衡,我们把他排除掉。

按星期几来看,谁的贡献占比最高?

如果只看提交次数: play
不过,贡献时间最长的贡献者——我们的创始人 Alexey——在这里可能确实有些优势。我们将分析范围限定在最近一年。 play
这仍然有些过于简单,无法真实反映人们的工作量。 一个更好的指标或许是:在过去一年里,每天的最高贡献者占当天总工作量的比例。请注意,我们对删减代码和新增代码一视同仁。 play

仓库中代码年龄分布

我们将分析范围限定为当前文件。为简洁起见,结果仅显示深度为 2 的内容,且每个根文件夹最多显示 5 个文件。可按需调整。 play

某位作者编写的代码中,有百分之多少被其他作者删除了?

对于这个问题,我们需要将某位作者编写的代码行数除以其他贡献者删除的总行数。 play

列出被重写次数最多的文件?

回答这个问题,最简单的方法可能就是直接统计每个 path 的行修改次数 (仅限当前文件) ,例如:
不过,这还没有体现“重写 (re-write) ”这一概念:也就是说,在某次提交中,文件有很大一部分内容发生了变化。这需要一个更复杂的查询。这里我们将重写定义为:文件中超过 50% 的内容被删除,且有 50% 的内容被新增。你可以根据自己对这一条件的理解,调整该查询。 该查询仅针对当前文件。我们按 pathcommit_hash 分组,列出所有文件变更,并返回新增和删除的行数。借助 window function,我们通过累积求和来估算文件在任意时刻的总大小,并将每次变更对文件大小的影响估算为 lines added - lines removed。基于这一统计值,我们可以计算每次变更中文件新增或删除所占的百分比。最后,我们统计每个文件中可视为重写的变更次数,即 (percent_add >= 0.5) AND (percent_delete >= 0.5) AND current_size > 50。注意,这里要求文件长度超过 50 行,以避免把文件早期少量内容的变更计为一次重写。这也能避免对特别小的文件产生偏差,因为这类文件更容易被重写。 play

代码在哪个工作日最有可能在仓库中保留得最久?

为此,我们需要唯一标识一行代码。由于同一行内容可能在一个文件中出现多次,我们用路径和行内容来近似确定其唯一性。 我们查询新增的代码行,并将其与被删除的代码行进行连接,再过滤出后者发生时间晚于前者的情况。这样就能得到后来被删除的代码行,并据此计算这两个事件之间的时间间隔。 最后,我们对整个数据集进行聚合,计算代码行按星期几划分时在仓库中保留的平均天数。 play

按平均代码年龄排序的文件

此查询使用了与代码在哪个工作日最有可能保留在仓库中相同的原理——通过路径和行内容唯一标识一行代码。 这样我们就能计算出某一行代码从添加到删除之间的时间。不过,这里只保留当前文件中的代码行,并按行计算每个文件的平均时间。 play

谁通常会编写更多测试 / CPP 代码 / 注释?

这个问题可以从几种不同的角度来回答。这里我们重点关注代码与测试的比例,因此这个查询相对简单——统计对包含 tests 的文件夹的贡献次数,并计算其占总贡献数的比例。 这里我们将查询范围限定为变更次数超过 20 次的用户,以便聚焦于经常提交代码的贡献者,并避免一次性贡献带来的偏差。 play
我们可以将这一分布绘制成直方图。 play
正如预期的那样,大多数贡献者编写的代码都比测试多。 那么,贡献代码时,谁添加的注释最多呢? play
注意,我们是按代码贡献排序的。令人意外的是,我们所有主要贡献者的这一比例都很高,而这也是我们的代码如此易于阅读的部分原因。

某位作者的提交在代码/注释占比方面随时间是如何变化的?

按作者计算这一点很简单,
不过,理想情况下,我们希望观察的是:从所有作者开始提交代码的第一天起,这一变化在整体上是如何演变的。随着时间推移,他们编写的注释是否会逐渐减少? 为此,我们首先需要计算每位作者随时间变化的注释占比——类似于 谁更倾向于编写更多测试 / CPP 代码 / 注释?。然后再将其与每位作者的开始日期关联起来,从而按周偏移量计算注释占比。 在计算出所有作者按周偏移量的平均值后,我们通过每隔 10 周选取一次,对结果进行采样。 play
令人欣慰的是,我们的注释占比相当稳定,不会因为作者贡献时间更长而下降。

代码在被重写前的平均时长是多少,中位数 (代码衰减的半衰期) 又是多少?

我们可以使用与 列出被重写次数最多或被最多作者重写的文件 相同的原理来识别重写,不过这里需要考虑所有文件。这里使用 窗口函数 来计算每个文件两次重写之间的时间间隔。基于此,我们可以进一步计算所有文件的平均值和中位数。 play

从代码最容易被重写这一点来看,什么时候写代码最不合适?

这与代码在被重写前的平均时间以及中位数 (代码衰减的半衰期) 是多少?列出被重写次数最多或由最多作者重写过的文件类似,只是这里按星期几进行聚合。可根据需要调整,例如改为按月份聚合。 play

哪位作者的代码粘性最高?

我们将“粘性”定义为:作者编写的代码在被重写前能保留多长时间。与前一个问题代码在被重写之前的平均时间是多少,以及中位数 (代码衰减的半衰期) 是多少?类似——这里使用相同的重写衡量标准,即某个文件中有 50% 为新增、50% 为删除。我们按作者计算平均重写时间,并且只统计关联文件数超过两个的贡献者。 play

某位作者最长的连续提交天数

此查询首先需要计算某位作者在哪些天有提交。通过使用窗口函数并按作者分区,我们可以算出每次提交与上一次提交之间相隔的天数。对于每次提交,如果距离上一次提交正好 1 天,就将其标记为连续 (1) ,否则标记为 0,并将结果存入 consecutive_day 接下来,我们使用数组函数来计算每位作者最长的连续 1 序列。首先,用 groupArray 函数汇总某位作者的所有 consecutive_day 值。然后,将这个由 1 和 0 组成的数组按值为 0 的位置拆分成多个子数组。最后,计算其中最长的子数组。 play

文件的逐行提交历史

文件可以重命名。发生这种情况时,会产生一个重命名事件,其中 path 列会设置为文件的新路径,而 old_path 表示其之前的位置,例如: play
这使得查看文件的完整历史变得很困难,因为我们没有一个统一的值来关联所有行或文件的变更。 为了解决这个问题,我们可以使用用户自定义函数 (UDFs) 。目前它们还不能递归调用,因此要识别文件的历史,我们必须定义一组彼此显式调用的 UDF。 这意味着我们只能跟踪到一定深度的重命名——下面的示例最多支持 5 层。文件被重命名超过这个次数的可能性很小,因此目前这已经足够。
通过调用 file_path_history('src/Storages/StorageReplicatedMergeTree.cpp'),我们会递归追溯其重命名历史,其中每个函数都会用 old_path 调用下一层。最终结果通过 arrayConcat 合并。 例如,
我们现在可以利用这一能力,整理出某个文件整个历史中的各次提交。在这个示例中,我们为每个 path 值展示一次提交。

待解决的问题

Git blame

由于目前还无法在 数组函数 中保留状态,因此很难得到精确结果。等有了 arrayFoldarrayReduce 之后,这一点就可以实现,因为它们允许在每次迭代中保存状态。 对于高层分析来说,一个近似解法通常就足够了,大致如下:
欢迎在此提供更准确、更完善的解决方案。
最后修改于 2026年6月25日