Tags
Cover Image

我低估了 PowerShell:生成 Git Commit Diff 列表的脚本案例

Zor Chen
Zor
最近需要在Windows环境下完成一些自动化操作,于是开始学习PowerShell脚本的编写。 本来对PowerShell比较无感,因为比较熟悉Bash Script,觉得PowerShell语法似乎比较啰嗦, 而且好多命令还要重头学起,自然就有了一定的抵触情绪。然而我还是太天真了,一切都逃不过真香定律。 本文将从一个实际遇到的需求出发展示PowerShell的强大之处,以及它对于熟悉C#的开发者来说有多么友好。

知识背景和开发环境

本文假设读者:

  • 有基础的git知识(知道git是干吗的);
  • 喜欢用命令行工具(CLI)来提高工作效率;
  • 有基础的Shell脚本编写或阅读经验;
  • 喜欢.NET 技术框架

尤其适合有.NET 开发背景的程序员食用。

PowerShell 和.NET 库能够完美配合,因此如果您有.NET (C#) 的开发背景, 那么对于脚本中的各种类型和函数会更容易理解,甚至都不用怎么去理解。

我的运行环境是PowerShell 7.0.2,经测试脚本在5.1版也能跑,更低的版本可能就悬了吧。 不过更低的版本估计也没人用,毕竟国内用的最多的Windows Server 2008 R2都可以装PowerShell 5.1了。

编辑器我用的是VS Code并安装了PowerShell插件, 该插件能够在编写 PowerShell 脚本时提供智能提示、格式化代码等功能。

需求说明

本文实现的其实是一个很简单的需求:

实现一个命令行脚本,调用方式为:

Terminal window
diff.ps1 [-verbose] [-baseDir BASE_DIR] [-out OUT_FILE] COMMIT_HASH
  1. 获取提交COMMIT_HASH之后的所有 Git 提交记录,记录中包括提交信息和变动的文件列表。

  2. 根据第 1 步得到的提交记录,生成:

    • 提交信息列表,按提交时间倒序排列;
    • BASE_DIR目录下的变动文件的路径列表,按路径升序排列,其中文件路径是基于BASE_DIR的相对路径,且不能重复
  3. 若通过-out参数指定了输出路径,则将上述信息写入OUT_FILE文件,否则在 stdout 中打印 JSON 数据。JSON 结构如下:

{
"commits": [...],
"filePaths": [...]
}
  1. 通过-verbose标记决定是否打印冗余信息,包括:

    • 查询到的提交记录
    • 被记录的变动文件路径

获取 Git 提交记录

首先要考虑如何获得便于解析的、带文件路径记录的 Git 提交记录。因为这个不是本文重点,在此我仅作简单说明。

假设要筛选的起始提交 Hash 为abc123,且不包含这个起始 Hash,则获取日志的命令如下:

Terminal window
git log --name-only --no-merges --dense --format="%n>>%h|%ai|%s" abc123..HEAD

简要参数说明如下,更准确的详细说明请参见 git-log Documentation

参数说明
--name-only仅显示变动的文件名(路径)
--no-merges不显示合并产生的提交记录,即将父级提交数量限制为1
--dense仅显示选定的提交信息
--format提供提交信息的格式,参数值指定的格式为:
(换行)>>短 Hash|类似 ISO-8601 提交时间|提交标题
<commit>..HEAD筛选出从<commit>到分支头部的提交记录,但不包含<commit>

用该命令打印出来的提交信息看起来是这样:

>>32aeee41|2020-06-16 02:04:14 +0800|commit 2
file1.txt
file2.bin
>>9a02956f|2020-06-16 01:44:26 +0800|commit 1
file0.exe
file1.txt

由于每个信息和文件路径都各占一行,因此不难想到只需要按行处理输出便可以得到我们所需的全部信息。 而有了格式化的提交信息,我们便可以很方便的使用正则表达式来进行数据提取了。

编写 PowerShell 脚本

有了上面的git命令作基础,剩下工作的就是使用 PowerShell 脚本来进行数据的提取、清洗、组装和输出了。

我本来以为用 PowerShell 处理器来会非常麻烦,因为毕竟 Windows 环境下缺少 Linux 下完善的工具集, 比如sedjq等数据处理工具。我甚至一度绝望地想到,难不成要我自己来手写 JSON 文件的解析和序列化, 或者上网找一些第三方工具作为依赖库?

但是我错了。

接下来你会看到 PowerShell 是如何利用强大的.NET 库来完成各种复杂工作, 甚至利用类、泛型和接口实现等类似 C# 的语言特性来更好的组织代码, 并在代码编写过程中使用IntelliSense来提高脚本编写效率。

在开始之前,新建一个脚本diff.ps1。后面的 PowerShell 代码都写在这个脚本里。

定义和解析命令行参数

Bash Script中,我们一般通过for + case指令块来解析命令行中的参数,比如差不多是这个意思:

Terminal window
for arg in $@
do
case $arg in
-verbose)
verbose=1
shift
;;
-baseDir)
shift
baseDir=$1
shift
;;
*)
-out)
shift
outFile=$1
shift
;;
afterCommit=$1
shift
;;
esac
done

而在 PowerShell 中,则是通过param关键字来声明一个参数列表:

Terminal window
param(
[string]$baseDir = "content/blog/",
[string]$out,
[Parameter(Position = 0, Mandatory = $true)] [string] $afterCommit
)

这段代码定义了:

  • 一个名为-baseDir可选 命名参数,为字符串类型。这里提供了一个默认值,当没有指定-baseDir时,默认筛选出博客文章的变化文件路径。
  • 一个名为-out可选 命名参数,为字符串类型。
  • 一个必填位置参数,该参数名为afterCommit。如果未在命令行中提供,则 PowerShell 会用这个名字来提示你需要输入的参数。

你可能已经注意到,我们在需求中有通过-verbose控制冗余信息是否打印的要求,但在这里并没有声明。这是因为-Verbose是一个 PowerShell 的保留参数,用来直接控制Write-Verbose是否打印到输出流。如果在这里定义了,反而会导致脚本运行错误(PowerShell 会提示定义了 多个名为Verbose的参数)。

这位问了,这代码每个变量还得声明类型吗?有点儿麻烦啊。

其实不声明类型的写法也是完全可以的,PowerShell 会在运行时进行类型推断;但我还是喜欢用强类型的写法,一则是多年来我自己形成的习惯, 更重要的是,在声明类型后才能充分享受编写 PowerShell 脚本时智能提示的便利。可以看一下下面这个例子:

变量类型成员智能提示变量类型成员智能提示

熟悉 C# 的朋友一定感到非常亲切:这不就是String类型的成员变量么!没有错,写 PowerShell 竟然让我找到了写 C#的快感,这是我始料未及的。

至此为止,我们仅用了三行代码就完成了命令行参数解析和默认值、限制条件等额外功能。真的有点儿爽。下面来处理业务逻辑。

遍历提交记录

在前文中已经得到了用于获取提交记录的git命令,现在是调用它的时候了。我们首先来遍历这条git命令打印的每一行输出信息, 并从中区分提交信息和文件路径。

为此,我们首先定义一个GetDiffList的函数,该函数接受两个参数:$startHash$relTo$startHash限制了 提交记录的查询起点,而$relTo则会用来筛选文件路径。

$startHash$relTo分别对应$afterCommit$baseDir这两个命令行参数的值。这里将其定义为函数参数, 是为了避免直接调用全局变量导致代码混乱,且便于对函数进行单元测试。

我们先来简单实现一下最基础的逻辑:

Terminal window
function GetDiffList ([string] $startHash, [string] $relTo) {
# 调用 git 命令
[string[]]$lines = git log --name-only --no-merges --dense --format="%n>>%h|%ai|%s" "$startHash..HEAD"
foreach ($line in $lines) {
$line = $line.Trim()
if ([String]::IsNullOrEmpty($line)) {
continue
}
elseif ($line -match ">>([a-z0-9]+?)\|(.+?)\|(.+)") {
# 正则匹配结果自动保存到内置的 $Matches 变量中
$hash = $Matches[1]
$datetime = $Matches[2]
$subject = $Matches[3]
Write-Verbose "$hash / $datetime / $subject"
}
elseif ($line.StartsWith($baseDir)) {
$relPath = $line.Substring($baseDir.Length)
Write-Verbose $relPath
}
}
}
# 调用函数并传参
GetDiffList $afterCommit $baseDir

该函数进行了如下操作:

  1. 执行git log命令,并将打印输出到一个名为$logs的字符串数组中;
  2. 遍历$lines的每一行,裁剪掉每行两头的空白字符,并忽略空行;
  3. 使用正则表达式匹配提交信息的输出行,并从中提取 Hash 值、提交时间和提交说明;
  4. 若文件路径是相对于$baseDir的,则截取出相对路径并打印。

值得一提的是,PowerShell 中调用静态方法的方式是[类型]::方法(),比如这里的

Terminal window
[String]:IsNullOrEmpty($line)

到目前为止,脚本执行效果如下:

简单遍历输出简单遍历输出

因为代码中是用Write-Verbose来输出的,因此在执行脚本时注意需要添加-Verbose标记。

用类和.NET 内置类型组织数据

上面获取到的数据是有嵌套结构的。如果能够在数据操作的过程中保留结构并结构化输出到 JSON,岂不美哉?

👍🏻️👍🏻️

上文说过 PowerShell 可以和.NET 库配合得天衣无缝,各种类啊泛型啊什么的更是不在话下。 下面来看看如何配合 PowerShell 强大的类型系统改造我们的脚本,来让数据结构更加合理。

首先来分析一下,最终生成的 JSON 文件所对应的数据结构应该如下:

数据关系数据关系

上图中可以看到,diff.jsonCommit都是带内嵌字段的复合结构,而commitsfilePaths是两个类似列表/数组的结构。按照这种设计, diff.jsonCommit应采用类或哈西表一类的数据结构,而commitsfilePaths应使用数组类的可遍历类型。

针对不同的数据结构,我们来用 PowerShell 分别实现一下看看。

列表类数据结构

我们需要commits中的元素按提交时间倒序排列,这一点git已经帮忙做好了,可以不用再处理,在此我们直接使用System.Collections.Generic.List<T>类型。

文件路径的添加顺序则不一定按名称升序排列,因此肯定要对该数组进行排序。 另外,由于文件路径不能重复,所以在向filePaths中添加路径时必须要验证新增路径是否已经存在于集合中。

.NET 中有一个数据类型完美契合上述两个需求:System.Collections.Generic.SortedSet<T>,即排序集合。顾名思义,它能够保证集合中的元素不会重复, 且能够根据某种规则将元素排序。

为了让SortedSet可以排序,我们需要在它的构造函数中提供一个比较器对象,比较器的类必须实现System.Collections.Generic.IComparer<T>接口。

相关代码如下:

Terminal window
# highlight-start
# 你甚至可以用 using namespace 来简化代码
using namespace System.Collections
using namespace System.Collections.Generic
# highlight-end
...
# highlight-start
# 这里是我们的比较器,按路径字符串升序排序,且不区分大小写
class FilePathComparer:IComparer[string] {
[CaseInsensitiveComparer]$caseiComp = [CaseInsensitiveComparer]::new()
[int] Compare([string]$a, [string]$b) {
return $this.caseiComp.Compare($a, $b)
}
}
# highlight-end
function GetDiffList (...) {
...
# highlight-start
# 创建 SortedSet 和 List ,注意这里展示了两种不同的 new 对象的方式
$filePaths = [SortedSet[string]]::new([FilePathComparer]::new())
$commits = New-Object List[Commit]
# highlight-end
foreach ($line in $lines) {
$line = $line.Trim()
if ([String]::IsNullOrEmpty($line)) {
continue
}
elseif ($line -match ">>([a-z0-9]+?)\|(.+?)\|(.+)") {
...
# highlight-range{1-2}
# $commit 对象会在下一节中创建
$commits.Add($commit)
}
elseif ($line.StartsWith($baseDir)) {
$relPath = $line.Substring($baseDir.Length)
Write-Verbose $relPath
# highlight-range{1-2}
# 要用管道操作将命令输出传递给空目标,否则会影响 return 的值
$filePaths.Add($relPath) | Out-Null
}
}
}
...

要注意的是,在 PowerShell 脚本中,泛型类型都是用[T]定义的(而非<T>),这一点可能需要习惯一下。

另外,在编写函数的过程中,如果内部有未使用的其他函数的返回值(比如本例中的SortedSet.Add()的返回值), 应将其通过管道操作符 | 传递给 Out-Null 指令,否则会被当作本函数的返回值传递出去。

复合类数据结构

下面用两种不同的方法来实现diff.jsonCommit,来展示一下 PowerShell 的简洁与强大。

首先我们用来实现Commit数据结构。代码非常直白非常简单,和 C# 的类声明没什么大区别。

Terminal window
...
# highlight-start
class Commit {
[string] $hash; # Commit Hash
[string] $datetime; # 提交的日期时间,简便起见这里不再转化成专用的数据类型了
[string] $subject; # 提交说明的主题
# Override ToString() 函数,让打印输出更漂亮
[string] ToString() {
return [String]::Format(
# PowerShell中,\n, \t等写作 `n, `t
"[{0}] {1}`n`t @ {2}",
$this.hash,
$this.subject,
$this.datetime)
}
}
# highlight-end
function GetDiffList(...) {
...
}
...

要初始化一个Commit类的实例,有两种方法:通过构造函数或直接用Hash 表转换。 在这里我觉得构造函数有点多余了,所以采用第二种方法,即构建一个 Hash 表然后进行类型转换。

通过 PowerShell 的@{}语法,我们能够很轻松的创建一个 Hash 表。看看,是不是有点 TypeScript 内味儿了?

Terminal window
function GetDiffList(...){
...
# highlight-start
$commit = [Commit] @{
hash = $Matches[1];
datetime = $Matches[2];
subject = $Matches[3];
}
Write-Verbose $commit
# highlight-end
$commits.Add($commit)
...
}

接下来我们用 Hash 表来实现GetDiffList函数的返回值,即diff.json的顶层结构。

Terminal window
function GetDiffList(...){
...
# highlight-start
return @{
commits = $commits;
filePaths = $filePaths;
}
# highlight-end
}

到此为止我们已经收集了所有需要的数据,是时候疯狂虚区了!

不是,图可能不太对不是,图可能不太对

将数据对象输出为 JSON 文件

终于到最后一步临门一脚了。

不过这一步实在没有什么可说的。我们既不用做任何结构化解析,也不用调用外部工具,只需要通过 PowerShell 自带的 ConvertTo-Json命令以及一系列管道操作符,即可完成数据转换和输出。

Terminal window
...
# highlight-start
# 先转成 JSON
$json = GetDiffList $afterCommit $baseDir | ConvertTo-Json
if ([string]::IsNullOrEmpty($out)) {
# 如果是 Dry-Run 或没有指定输出文件,则直接打印在控制台中
Write-Host $json
}
else {
# 否则写到文件里
$json | Out-File $out
}
# highlight-end

除了 ConvertTo-Json,PowerShell 还有ConvertTo-HTMLConvertTo-CsvConvertTo-Xml等一些列常用转换指令, 以及对应的ConvertFrom-版本。说实话,这一点我是服微软的。

在我的博客仓库中按如下方式执行脚本:

Terminal window
.\diff.ps1 -out diff.json 8534dfe55

可以得到如下的文件:

点击查看

完整diff.ps1代码

Terminal window
using namespace System.Collections
using namespace System.Collections.Generic
param(
[string]$baseDir = "content/blog/",
[switch]$dryRun,
[string]$out,
[Parameter(Position = 0, Mandatory = $true)] [string] $afterCommit
)
class FilePathComparer:IComparer[string] {
[CaseInsensitiveComparer]$caseiComp = [CaseInsensitiveComparer]::new()
[int] Compare([string]$a, [string]$b) {
return $this.caseiComp.Compare($a, $b)
}
}
class Commit {
[string] $hash;
[string] $datetime;
[string] $subject;
[string] ToString() {
return [String]::Format("[{0}] {1}`n`t @ {2}", $this.hash, $this.subject, $this.datetime)
}
}
function GetDiffList ([string] $startHash, [string] $relTo) {
[string[]]$lines = git log --name-only --no-merges --dense --format="%n>>%h|%ai|%s" "$startHash..HEAD"
$filePaths = [SortedSet[string]]::new([FilePathComparer]::new())
$commits = New-Object List[Commit]
foreach ($line in $lines) {
$line = $line.Trim()
if ([String]::IsNullOrEmpty($line)) {
continue
}
elseif ($line -match ">>([a-z0-9]+?)\|(.+?)\|(.+)") {
$commit = [Commit] @{
hash = $Matches[1];
datetime = $Matches[2];
subject = $Matches[3];
}
Write-Verbose $commit
$commits.Add($commit)
}
elseif ($line.StartsWith($baseDir)) {
$relPath = $line.Substring($baseDir.Length)
Write-Verbose $relPath
$filePaths.Add($relPath) | Out-Null
}
}
return @{
commits = $commits;
filePaths = $filePaths;
}
}
$json = GetDiffList $afterCommit $baseDir | ConvertTo-Json
if ($dryRun -or [string]::IsNullOrEmpty($out)) {
Write-Host $json
}
else {
$json | Out-File $out
}

后记

说实话,这篇文章写完以后我都觉得我有点给微软捧臭脚的意思,谄媚之情充溢于字里行间。

但是作为从毕业第一份工作就是 C# ,后来又做了 N 年 Unity 的老刀奶特Old .NET, PowerShell 脚本编程带给我的体验如黑丝般顺滑,以至于我现在就是非常后悔没有早点用 PowerShell 来写 各种脚本。

在写脚本的过程中,我的学习成本几乎为 0,偶尔需要去查一下微软的文档库,剩下的多是类似这样的惊喜时刻:

“哎?这里是不是跟 C#一样也能这么做?我试试……”

“我去还真行?!”

不过咱有一说一,PowerShell 面临的最大问题并不在于提高易用性,抑或如何发展背后强大的技术, 而是如何打破与后端开发者及运维工程师之间的壁垒,去掉脑袋上Windows专属 + 难用 + 丑的标签, 让开发者愿意共同构建 PowerShell 生态。

我很高兴看到 PowerShell 已经可以跨平台了,也在自己的 WSL Ubuntu 环境下安装了 PowerShell 来尝鲜, 但依然无法说服自己在使用 Linux 时选择PowerShell作为 shell 而非oh-my-zsh。这其中各种细节总会多多少少泼点儿冷水, 从自动补全到历史回溯,从工具集成到花花绿绿,PowerShell 真的还有不少路要走。

不过我还是很期待。