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

知识背景和开发环境
本文假设读者:
- 有基础的
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 脚本时提供智能提示、格式化代码等功能。
需求说明
本文实现的其实是一个很简单的需求:
实现一个命令行脚本,调用方式为:
diff.ps1 [-verbose] [-baseDir BASE_DIR] [-out OUT_FILE] COMMIT_HASH
-
获取提交
COMMIT_HASH
之后的所有 Git 提交记录,记录中包括提交信息和变动的文件列表。 -
根据第 1 步得到的提交记录,生成:
- 提交信息列表,按提交时间倒序排列;
BASE_DIR
目录下的变动文件的路径列表,按路径升序排列,其中文件路径是基于BASE_DIR
的相对路径,且不能重复
-
若通过
-out
参数指定了输出路径,则将上述信息写入OUT_FILE
文件,否则在 stdout 中打印 JSON 数据。JSON 结构如下:
{ "commits": [...], "filePaths": [...]}
-
通过
-verbose
标记决定是否打印冗余信息,包括:- 查询到的提交记录
- 被记录的变动文件路径
获取 Git 提交记录
首先要考虑如何获得便于解析的、带文件路径记录的 Git 提交记录。因为这个不是本文重点,在此我仅作简单说明。
假设要筛选的起始提交 Hash 为abc123
,且不包含这个起始 Hash,则获取日志的命令如下:
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.txtfile2.bin
>>9a02956f|2020-06-16 01:44:26 +0800|commit 1
file0.exefile1.txt
由于每个信息和文件路径都各占一行,因此不难想到只需要按行处理输出便可以得到我们所需的全部信息。 而有了格式化的提交信息,我们便可以很方便的使用正则表达式来进行数据提取了。
编写 PowerShell 脚本
有了上面的git
命令作基础,剩下工作的就是使用 PowerShell 脚本来进行数据的提取、清洗、组装和输出了。
我本来以为用 PowerShell 处理器来会非常麻烦,因为毕竟 Windows 环境下缺少 Linux 下完善的工具集,
比如sed
、jq
等数据处理工具。我甚至一度绝望地想到,难不成要我自己来手写 JSON 文件的解析和序列化,
或者上网找一些第三方工具作为依赖库?
但是我错了。
接下来你会看到 PowerShell 是如何利用强大的.NET 库来完成各种复杂工作,
甚至利用类、泛型和接口实现等类似 C# 的语言特性来更好的组织代码,
并在代码编写过程中使用IntelliSense
来提高脚本编写效率。
在开始之前,新建一个脚本diff.ps1
。后面的 PowerShell 代码都写在这个脚本里。
定义和解析命令行参数
在Bash Script
中,我们一般通过for
+ case
指令块来解析命令行中的参数,比如差不多是这个意思:
for arg in $@do case $arg in -verbose) verbose=1 shift ;; -baseDir) shift baseDir=$1 shift ;; *) -out) shift outFile=$1 shift ;; afterCommit=$1 shift ;; esacdone
而在 PowerShell 中,则是通过param
关键字来声明一个参数列表:
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
这两个命令行参数的值。这里将其定义为函数参数, 是为了避免直接调用全局变量导致代码混乱,且便于对函数进行单元测试。
我们先来简单实现一下最基础的逻辑:
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
该函数进行了如下操作:
- 执行
git log
命令,并将打印输出到一个名为$logs
的字符串数组中; - 遍历
$lines
的每一行,裁剪掉每行两头的空白字符,并忽略空行; - 使用正则表达式匹配提交信息的输出行,并从中提取 Hash 值、提交时间和提交说明;
- 若文件路径是相对于
$baseDir
的,则截取出相对路径并打印。
值得一提的是,PowerShell 中调用静态方法的方式是[类型]::方法()
,比如这里的
[String]:IsNullOrEmpty($line)
到目前为止,脚本执行效果如下:
简单遍历输出
因为代码中是用Write-Verbose
来输出的,因此在执行脚本时注意需要添加-Verbose
标记。
用类和.NET 内置类型组织数据
上面获取到的数据是有嵌套结构的。如果能够在数据操作的过程中保留结构并结构化输出到 JSON,岂不美哉?
👍🏻️
上文说过 PowerShell 可以和.NET 库配合得天衣无缝,各种类啊泛型啊什么的更是不在话下。 下面来看看如何配合 PowerShell 强大的类型系统改造我们的脚本,来让数据结构更加合理。
首先来分析一下,最终生成的 JSON 文件所对应的数据结构应该如下:
数据关系
上图中可以看到,diff.json
和Commit
都是带内嵌字段的复合结构,而commits
和filePaths
是两个类似列表/数组的结构。按照这种设计,
diff.json
和Commit
应采用类或哈西表一类的数据结构,而commits
和filePaths
应使用数组类的可遍历类型。
针对不同的数据结构,我们来用 PowerShell 分别实现一下看看。
列表类数据结构
我们需要commits
中的元素按提交时间倒序排列,这一点git
已经帮忙做好了,可以不用再处理,在此我们直接使用System.Collections.Generic.List<T>
类型。
文件路径的添加顺序则不一定按名称升序排列,因此肯定要对该数组进行排序。
另外,由于文件路径不能重复,所以在向filePaths
中添加路径时必须要验证新增路径是否已经存在于集合中。
.NET 中有一个数据类型完美契合上述两个需求:System.Collections.Generic.SortedSet<T>
,即排序集合。顾名思义,它能够保证集合中的元素不会重复,
且能够根据某种规则将元素排序。
为了让SortedSet
可以排序,我们需要在它的构造函数中提供一个比较器对象,比较器的类必须实现System.Collections.Generic.IComparer<T>
接口。
相关代码如下:
# highlight-start# 你甚至可以用 using namespace 来简化代码using namespace System.Collectionsusing 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.json
和Commit
,来展示一下 PowerShell 的简洁与强大。
首先我们用类来实现Commit
数据结构。代码非常直白非常简单,和 C# 的类声明没什么大区别。
...
# highlight-startclass 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 内味儿了?
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
的顶层结构。
function GetDiffList(...){ ... # highlight-start return @{ commits = $commits; filePaths = $filePaths; } # highlight-end}
到此为止我们已经收集了所有需要的数据,是时候疯狂虚区了!
不是,图可能不太对
将数据对象输出为 JSON 文件
终于到最后一步临门一脚了。
不过这一步实在没有什么可说的。我们既不用做任何结构化解析,也不用调用外部工具,只需要通过 PowerShell 自带的
ConvertTo-Json
命令以及一系列管道操作符,即可完成数据转换和输出。
...# highlight-start# 先转成 JSON$json = GetDiffList $afterCommit $baseDir | ConvertTo-Jsonif ([string]::IsNullOrEmpty($out)) {# 如果是 Dry-Run 或没有指定输出文件,则直接打印在控制台中 Write-Host $json}else { # 否则写到文件里 $json | Out-File $out}# highlight-end
除了 ConvertTo-Json
,PowerShell 还有ConvertTo-HTML
、ConvertTo-Csv
、ConvertTo-Xml
等一些列常用转换指令,
以及对应的ConvertFrom-
版本。说实话,这一点我是服微软的。
在我的博客仓库中按如下方式执行脚本:
.\diff.ps1 -out diff.json 8534dfe55
可以得到如下的文件:
点击查看

完整diff.ps1
代码
using namespace System.Collectionsusing 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-Jsonif ($dryRun -or [string]::IsNullOrEmpty($out)) { Write-Host $json}else { $json | Out-File $out}
后记
说实话,这篇文章写完以后我都觉得我有点给微软捧臭脚的意思,谄媚之情充溢于字里行间。
但是作为从毕业第一份工作就是 C# ,后来又做了 N 年 Unity 的老刀奶特, PowerShell 脚本编程带给我的体验如黑丝般顺滑,以至于我现在就是非常后悔没有早点用 PowerShell 来写 各种脚本。
在写脚本的过程中,我的学习成本几乎为 0,偶尔需要去查一下微软的文档库,剩下的多是类似这样的惊喜时刻:
“哎?这里是不是跟 C#一样也能这么做?我试试……”
“我去还真行?!”
不过咱有一说一,PowerShell 面临的最大问题并不在于提高易用性,抑或如何发展背后强大的技术,
而是如何打破与后端开发者及运维工程师之间的壁垒,去掉脑袋上Windows专属 + 难用 + 丑
的标签,
让开发者愿意共同构建 PowerShell 生态。
我很高兴看到 PowerShell 已经可以跨平台了,也在自己的 WSL Ubuntu 环境下安装了 PowerShell 来尝鲜,
但依然无法说服自己在使用 Linux 时选择PowerShell
作为 shell 而非oh-my-zsh
。这其中各种细节总会多多少少泼点儿冷水,
从自动补全到历史回溯,从工具集成到花花绿绿,PowerShell 真的还有不少路要走。
不过我还是很期待。