git-format-staged实现提交时自动格式化及其原理

43 阅读12分钟

背景

在工作中,我发现项目里的代码,很多是没有格式化的。

为了解决这个问题,我们使用git pre-commit hook + git-format-staged实现了自动格式化。

配置了自动格式之后,不用手动格式化代码了,把没有格式化的代码,commit之后,就是格式化后的代码。现在项目里每个commit的几乎每一行都是格式化之后的了(除了特定的排除的文件)

这个文章说明一下git-format-staged实现自动格式化的使用方式和原理。

使用方式示例

  1. 设置了pre-commit hook, 使用了git-format-staged
  2. 尽量保证已有代码是格式化过的(有一些没格式化也行)
  3. 添加了2行格式有误的代码,但是只git add 一行到 index

然后commit. commit里的内容就是格式化之后的了,也没有影响没有git add的内容。

这个原理,需要对git有一定的了解,才能懂。最近几天学了一下git的基本原理,把这个git-format-staged的原理差不多搞懂了。这里说明一下。git是个极其复杂的东西,以下只是我的理解,有的地方可能不太准确,大致看看就行了。

了解一下Git

Git 的 4 种核心对象

说明

这4种基本对象,每个对象对应.git/objects/文件夹里的一个文件。每个对象有每个对象的hash,hash基本上就是对象在git数据库里的路径。所以在git里可以把hash当作指针。

Blobs

文件快照

Commits

就是当前项目的一个快照。注意,是快照,不是和上个commit的差异

注意,在 git 内部储存里,commit不是把整个项目重新保存一遍,而是一个commit对象里有一个tree对象的hash,和commit元信息。

Trees

文件夹快照。里面保存着子文件夹的快照和文件夹内的文件的快照

在git内部储存里,tree 对象里保存的是文件快照hash和子文件夹tree的hash。这样,如果文件内容没有改动,或者子文件夹没有改动,就能复用。

Tags

标签

HEAD

是一个指针,指向当前所在的commit

Git 的工作区(Working Directory或者称为Working Tree)

简单地说,就是这个项目当前实际的状态

Git 暂存区 (Index. 也称为Staging Area,暂存区)

一般来说,commit之前,需要把文件通过git add 添加到index,然后commit。

也就是说,index和下一个commit的内容是一致的

index的内容是当前项目的快照。注意是快照,不是差异。

HEAD + staged changes => Index

Index + unstaged changed => Working Directory

git add 的核心是,给你要add的文件内容,创建快照,给index更新这个文件的快照。

如果你只add某个文件的一部分修改,那这个快照的内容就是只有这一部分修改的这个文件。

准备一个示例项目

项目在这里 Xyc2016/learn-git-format-staged

这个示例项目的关键是只把一部分修改添加到index,剩下一部分没有添加的index.

learn uv init learn-git-format-staged
Initialized project `learn-git-format-staged` at `/home/xyc/learn/learn-git-format-staged`
➜  learn cd learn-git-format-stagedlearn-git-format-staged git:(master) ✗ git add -Alearn-git-format-staged git:(master) ✗ git commit -m 'Init.'
[master (root-commit) 353c9e7] Init.
 5 files changed, 24 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 .python-version
 create mode 100644 README.md
 create mode 100644 main.py
 create mode 100644 pyproject.tomllearn-git-format-staged git:(master) cat main.py # 注意当前commitmain.py文件的内容,没有foo bar
def main():
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    main()
➜  learn-git-format-staged git:(master) hx main.py # 编辑文件添加两行需要没格式化的代码
➜  learn-git-format-staged git:(master) ✗ cat main.py
def main():
    print("foo" )
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()
➜  learn-git-format-staged git:(master) ✗ git add -p # 这一步是交互式地添加部分修改到index. 只把print("foo" )添加了。省略这部分的输出
➜  learn-git-format-staged git:(master) ✗ git diff --cached | cat # 可知indexHEAD的区别是多了print("foo" )
diff --git a/main.py b/main.py
index 30fab57..a551a34 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
 def main():
+    print("foo" )
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ git diff | cat # 可知working directoryindex的区别是多了print("bar" )
diff --git a/main.py b/main.py
index a551a34..abaf1ab 100644
--- a/main.py
+++ b/main.py
@@ -4,4 +4,5 @@ def main():


 if __name__ == "__main__":
+    print("bar" )
     main()
➜  learn-git-format-staged git:(master) ✗ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   main.py

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   main.pylearn-git-format-staged git:(master) ✗ cat main.py
def main():
    print("foo" )
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()
➜  learn-git-format-staged git:(master) ✗  

这样就准备了一个示例项目,接下来一边看git-format-staged的源码,一边使用命令行模拟操作

分步骤学习git-format-staged的源码

注意以下示例代码都是大幅度简化后的

  1. 找到stage了哪些修改

def format_staged_files(file_patterns, formatter, git_root, update_working_tree=True, write=True, verbose=False):
    output = subprocess.check_output([
        'git', 'diff-index',
        '--cached',
        '--diff-filter=AM', # select only file additions and modifications
        '--no-renames',
        'HEAD'
        ])
    for line in output.splitlines():
        entry = parse_diff(line.decode('utf-8')) # 这是一个dict,里面有修改前后的文件快照等信息
        format_file_in_index(formatter, entry, update_working_tree=update_working_tree, write=write, verbose=verbose) # 对每个修改的文件,执行format_file_in_index. 这个函数的内容就是接下来的步骤2、3、4。
➜  learn-git-format-staged git:(master) ✗ git diff-index --cached --diff-filter=AM --no-renames HEAD # 列出暂存区(已 git add 的文件)中相对于 HEAD 提交的「新增(Added)」和「修改(Modified)」文件路径,和它在HEAD里、index里的快照hash
:100644 100644 30fab57460d656637f2b4e272a6fe7b07780658b a551a34f45146c4416ab179ff660873fcaf17251 M      main.py

# git cat-file -p HASH 可以用来打印文件快照的内容,还有其它用途
➜  learn-git-format-staged git:(master) ✗ git cat-file -p 30fab57460d656637f2b4e272a6fe7b07780658b # 此文件在HEAD里的快照
def main():
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    main()
➜  learn-git-format-staged git:(master) ✗ git cat-file -p a551a34f45146c4416ab179ff660873fcaf17251 # 此文件在index里的快照
def main():
    print("foo" )
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    main()

2. ## 把Index里的这个文件快照格式化,写入git database

def format_object(formatter, object_hash, file_path, verbose=False):
    get_content = subprocess.Popen(
            ['git', 'cat-file', '-p', object_hash],
            stdout=subprocess.PIPE
            )
    command = re.sub(file_path_placeholder, file_path, formatter) # 把文件名传给命令模板,生成具体的命令。这里是 ruff format --stdin-filename "main.py"

    format_content = subprocess.Popen(
            command,
            shell=True,
            stdin=get_content.stdout,
            stdout=subprocess.PIPE
            )
    write_object = subprocess.Popen(
            ['git', 'hash-object', '-w', '--stdin'],
            stdin=format_content.stdout,
            stdout=subprocess.PIPE
            )

    new_hash, err = write_object.communicate()

    return new_hash.decode('utf-8').rstrip()

这段代码有这3个步骤

➜  learn-git-format-staged git:(master) ✗ git cat-file -p a551a34f45146c4416ab179ff660873fcaf17251 | ruff format --stdin-filename "main.py" | git hash-object -w --stdin
7cd583d924f78f37f3d206f0ba04a497d10bceaa
# git cat-file -p a551a34f45146c4416ab179ff660873fcaf17251 打印a551a34f45146c4416ab179ff660873fcaf17251这个hash对应的快照(步骤1)
# ruff format --stdin-filename "main.py" 是把main.py文件里的内容通过stdin输入 (步骤2)
# git hash-object -w --stdin 是把从stdin接受的内容,做个快照,写入git database (步骤3)

3. ## 把index里的文件快照换成格式化之后的

def replace_file_in_index(diff_entry, new_object_hash):
    subprocess.check_call(['git', 'update-index',
        '--cacheinfo', '{},{},{}'.format(
            diff_entry['dst_mode'],
            new_object_hash,
            diff_entry['src_path']
            )])

尝试一下

➜  learn-git-format-staged git:(master) ✗ git diff --cached | cat # git diff --cached是输出index和HEAD的区别
diff --git a/main.py b/main.py
index 30fab57..a551a34 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
 def main():
+    print("foo" )
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ git update-index --cacheinfo 100644,7cd583d924f78f37f3d206f0ba04a497d10bceaa,main.py
➜  learn-git-format-staged git:(master) ✗ git diff --cached | cat
diff --git a/main.py b/main.py
index 30fab57..7cd583d 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
 def main():
+    print("foo")
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ git diff | cat # git diff 是输出working directory和index的区别
diff --git a/main.py b/main.py
index 7cd583d..abaf1ab 100644
--- a/main.py
+++ b/main.py
@@ -1,7 +1,8 @@
 def main():
-    print("foo")
+    print("foo" )
     print("Hello from learn-git-format-staged!")


 if __name__ == "__main__":
+    print("bar" )
     main()
➜  learn-git-format-staged git:(master) ✗ cat main.py                                                                                                                                                                                                       
def main():
    print("foo" )
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()

可以发现,暂存区的那个修改被格式化了。但是,工作区没变,foo那一行还是没有格式化。

  1. 把格式化修改同步到工作区

def patch_working_file(path, orig_object_hash, new_object_hash):
    patch = subprocess.check_output(
            ['git', 'diff', '--no-ext-diff', '--color=never', orig_object_hash, new_object_hash]
            )

    # Substitute object hashes in patch header with path to working tree file
    patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(new_object_hash.encode(), path.encode())

    apply_patch = subprocess.Popen(
            ['git', 'apply', '-'],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE
            )

    output, err = apply_patch.communicate(input=patch_b)
learn-git-format-staged git:(master) ✗ git diff --no-ext-diff --color=never a551a34f45146c4416ab179ff660873fcaf17251 7cd583d924f78f37f3d206f0ba04a497d10bceaa | cat     # 找到index里的main.py文件快照在格式化前后的差异
diff --git a/a551a34f45146c4416ab179ff660873fcaf17251 b/7cd583d924f78f37f3d206f0ba04a497d10bceaa
index a551a34..7cd583d 100644
--- a/a551a34f45146c4416ab179ff660873fcaf17251
+++ b/7cd583d924f78f37f3d206f0ba04a497d10bceaa
@@ -1,5 +1,5 @@
 def main():
-    print("foo" )
+    print("foo")
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ git diff --no-ext-diff --color=never a551a34f45146c4416ab179ff660873fcaf17251 7cd583d924f78f37f3d206f0ba04a497d10bceaa | sd a551a34f45146c4416ab179ff660873fcaf17251 main.py | sd 7cd583d924f78f37f3d206f0ba04a497d10bceaa main.py
diff --git a/main.py b/main.py
index a551a34..7cd583d 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,5 @@
 def main():
-    print("foo" )
+    print("foo")
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ cat main.py                                                                                                                                                                                                       
def main():
    print("foo" )
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()
➜  learn-git-format-staged git:(master) ✗ git diff --no-ext-diff --color=never a551a34f45146c4416ab179ff660873fcaf17251 7cd583d924f78f37f3d206f0ba04a497d10bceaa | sd a551a34f45146c4416ab179ff660873fcaf17251 main.py | sd 7cd583d924f78f37f3d206f0ba04a497d10bceaa main.py | git apply - # 把这个差异,使用git apply应用到工作区文件上
➜  learn-git-format-staged git:(master) ✗ cat main.py                                                                                                                                                                                                       
def main():
    print("foo")
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()

# 这样就把foo这个修改,在indexworking directory都格式化好了,可以commit了
➜  learn-git-format-staged git:(master) ✗ git commit -m 'Add print("foo")'                                                                                                                                                                                  
[master a50426c] Add print("foo")
 1 file changed, 1 insertion(+)
➜  learn-git-format-staged git:(master) ✗ git showlearn-git-format-staged git:(master) ✗ git show | cat
commit a50426c100628da304b42d9806891954acc39932
Author: xuyucai <986327386@qq.com>
Date:   Sun May 4 20:14:00 2025 +0800

    Add print("foo")

diff --git a/main.py b/main.py
index 30fab57..7cd583d 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
 def main():
+    print("foo")
     print("Hello from learn-git-format-staged!")


➜  learn-git-format-staged git:(master) ✗ cat main.py # foo那一行commit了,没有影响到没有add的行print("bar" )
def main():
    print("foo")
    print("Hello from learn-git-format-staged!")


if __name__ == "__main__":
    print("bar" )
    main()

版权声明

本文中关于 git-format-staged 的代码示例和实现原理解析,基于原仓库 hallettj/git-format-staged,遵循 MIT 许可证。

思考

以下方法是不是更好

  1. 把添加到index里的快照格式化一下,更新index
  2. 把working directory里的相关文件格式化一下,更新working directory

简单试了一下发现这个方案有个问题:如果代码格式有误,例如少了括号,formatter(例如ruff)会报错,无法格式化。而git-format-staged这个方案,只要保证add到index的内容的格式无误即可。