git 命令写成脚本_我低估了PowerShell:一个提取Git提交记录并生成文件Diff列表的脚本案例...

本文介绍如何用PowerShell编写脚本,获取Git提交记录并生成文件Diff列表。脚本解析Git命令输出,使用.NET库处理数据,支持命令行参数,输出结构化的JSON文件。适合有.NET背景的程序员,能在Windows和WSL环境下运行。
最近需要在 Windows 环境下完成一些自动化操作,于是开始学习 PowerShell 脚本的编写。 本来对 PowerShell 比较无感,因为比较熟悉 Bash Script,觉得 PowerShell 语法似乎比较啰嗦, 而且好多命令还要重头学起,自然就有了一定的抵触情绪。然而我还是太天真了,一切都逃不过真香定律。
本文将从一个实际遇到的需求出发展示 PowerShell 的强大之处,以及它对于熟悉 C#的开发者来说有多么友好。
本文首发于本人博客0x1C.dev,欢迎访问以获得更好的阅读体验。

0x0 知识背景和开发环境

本文假设读者:

  • 有基础的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 脚本时提供智能提示、格式化代码等功能。


0x1 需求说明

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

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

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": [...]
}

4. 通过-verbose标记决定是否打印冗余信息,包括:

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

0x2 获取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.txt
file2.bin

>>9a02956f|2020-06-16 01:44:26 +0800|commit 1

file0.exe
file1.txt

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


0x3 编写PowerShell脚本

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

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

但是我错了。

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

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

0x3 >> 1 定义和解析命令行参数

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
    ;;
  esac
done

而在 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 脚本时智能提示的便利。可以看一下下面这个例子:

7373ff82e9dff5489810a58d345d05ee.gif
VS Code PowerShell插件 智能提示

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

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

0x3 >> 2 遍历提交记录

在前文中已经得到了用于获取提交记录的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

该函数进行了如下操作:

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

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

[String]:IsNullOrEmpty($line)

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

3b1fe691f20c379b172d4f565e15da78.png
简单的遍历-筛选-输出

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

0x3 >> 3 用类和.NET内置类型组织数据

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

0aec19b1baa825575fc4765ba6806030.png
岂不美哉? ️

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

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

05d2a9c38db639ad5406976b351d36db.png
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>接口。

相关代码如下:

# 你甚至可以用 using namespace 来简化代码
using namespace System.Collections
using namespace System.Collections.Generic
...

# 这里是我们的比较器,按路径字符串升序排序,且不区分大小写
class FilePathComparer:IComparer[string] {
  [CaseInsensitiveComparer]$caseiComp = [CaseInsensitiveComparer]::new()
  [int] Compare([string]$a, [string]$b) {
    return $this.caseiComp.Compare($a, $b)
  }
}
function GetDiffList (...) {
  ...
  # 创建 SortedSet 和 List ,注意这里展示了两种不同的 new 对象的方式
  $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 对象会在下一节中创建
      $commits.Add($commit)
    }
    elseif ($line.StartsWith($baseDir)) {
      $relPath = $line.Substring($baseDir.Length)
      Write-Verbose $relPath
      # 要用管道操作将命令输出传递给空目标,否则会影响 return 的值
      $filePaths.Add($relPath) | Out-Null
    }
  }
}
...

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

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

复合类数据结构

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

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

...
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)
  }
}

function GetDiffList(...) {
  ...
}
...

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

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

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

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

function GetDiffList(...){
  ...
  return @{
    commits   = $commits;
    filePaths = $filePaths;
  }
}

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

6fde3bd17bdd5da08bed0b9497881263.png
不是,我可能放错图了……

0x3 >> 4 将数据对象输出为JSON文件

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

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

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

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

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

.diff.ps1 -out diff.json 8534dfe55

可以得到如下的文件:

64304574fcd04224eff9d02007def8e3.png

0x4 后记

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

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

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

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

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

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

不过我还是很期待。


我说知乎你好歹也是个创作社区,就不能提供个正常点的编辑器吗?好好做一个支持完整Markdown语法的编辑器有这么难吗?每次发文章我都想死。鉴于知乎的编辑器太屎了,diff1.ps的完整脚本代码我就不贴在这了,感兴趣的朋友可以在我的博客页面查看,也希望能多多指教。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值