背景
在工作中,我发现项目里的代码,很多是没有格式化的。
为了解决这个问题,我们使用git pre-commit hook
+ git-format-staged
实现了自动格式化。
配置了自动格式之后,不用手动格式化代码了,把没有格式化的代码,commit之后,就是格式化后的代码。现在项目里每个commit的几乎每一行都是格式化之后的了(除了特定的排除的文件)
这个文章说明一下git-format-staged
实现自动格式化的使用方式和原理。
使用方式示例
- 设置了
pre-commit hook
, 使用了git-format-staged
- 尽量保证已有代码是格式化过的(有一些没格式化也行)
- 添加了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-staged
➜ learn-git-format-staged git:(master) ✗ git add -A
➜ learn-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.toml
➜ learn-git-format-staged git:(master) cat main.py # 注意当前commit里main.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 # 可知index和HEAD的区别是多了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 directory和index的区别是多了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.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-format-staged的源码,一边使用命令行模拟操作
分步骤学习git-format-staged的源码
注意以下示例代码都是大幅度简化后的
-
找到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那一行还是没有格式化。
-
把格式化修改同步到工作区
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这个修改,在index和working 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 show
➜ learn-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 许可证。
思考
以下方法是不是更好
- 把添加到index里的快照格式化一下,更新index
- 把working directory里的相关文件格式化一下,更新working directory
简单试了一下发现这个方案有个问题:如果代码格式有误,例如少了括号,formatter(例如ruff)会报错,无法格式化。而git-format-staged这个方案,只要保证add到index的内容的格式无误即可。