Git status 给脚本用:文件名带换行时,怎样正确解析改名记录
人能读懂的状态列表,不适合随手按行切开
脚本想找出工作区里有哪些修改,常见起点是调用 git status,再按换行拆成多条记录。但文件名本身可以包含换行、制表符和空格,改名输出还会同时涉及两个路径。一次随手拆分,就可能把一份文件误识别成几条状态,后续统计和处理全部错位。
机器读取应固定使用面向脚本的输出格式,并把字段边界当成协议。本文选择 porcelain 第一版加 -z:路径按原字节输出,用零字节分隔,不需要解析人类界面里的引号和反斜杠转义。这与把普通短格式的最后换行替换一下并不完全相同。
先保留字节,再按状态消费路径
下面需要 Python 三与 Git,在 Linux 的独立临时仓库运行。脚本创建包含特殊字符的文件,做一次演示提交,然后制造已暂存改名、未暂存修改和未跟踪文件。提交只存在于临时目录,使用虚构身份,继承配置与钩子来源均被隔离,不会触碰当前项目。
解析函数接收 bytes,先确认末尾零字节,再读取固定两字节状态与其后的空格。普通记录消耗一个路径,改名或复制记录额外消耗一个来源路径。代码还用缺失终止符和不完整改名做反例,避免损坏输出被悄悄当成完整结果。
AI概念配图,非真实界面
import os
import pathlib
import subprocess
import tempfile
def parse_status(raw):
if not raw:
return []
if not raw.endswith(b'\0'):
raise ValueError('missing final NUL')
fields = raw[:-1].split(b'\0')
records, pos = [], 0
while pos < len(fields):
item = fields[pos]
pos += 1
if len(item) < 4 or item[2:3] != b' ':
raise ValueError('invalid status record')
state, target = item[:2], item[3:]
source = None
if any(flag in state for flag in (b'R', b'C')):
if pos >= len(fields) or not fields[pos]:
raise ValueError('missing rename/copy source')
source = fields[pos]
pos += 1
records.append((state, target, source))
return records
assert parse_status(b'') == []
assert parse_status(b'?? a\nb\0') == [(b'??', b'a\nb', None)]
for broken in [b'?? partial', b'R target\0', b'bad\0']:
try:
parse_status(broken)
except ValueError:
pass
else:
raise AssertionError('malformed data accepted')
with tempfile.TemporaryDirectory(prefix='git-status-demo-') as folder:
base = pathlib.Path(folder)
repo, empty = base / 'repo', base / 'empty'
repo.mkdir()
empty.mkdir()
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
GIT_ATTR_NOSYSTEM='1')
def git(*args):
return subprocess.run(
['git', '-c', 'core.hooksPath=' + str(empty),
'-c', 'core.attributesFile=' + os.devnull,
'-c', 'user.name=Demo', '-c', 'user.email=demo@example.invalid',
'-c', 'commit.gpgsign=false', *args],
cwd=repo, env=env, check=True, capture_output=True).stdout
git('init', '--quiet', '--template=' + str(empty))
old, new, changed = 'old -> label.txt', 'new\nname.txt', 'tab\tname.txt'
(repo / old).write_bytes(b'original content\n')
(repo / changed).write_bytes(b'before\n')
git('add', '--', old, changed)
git('commit', '--quiet', '-m', 'isolated fixture')
git('mv', '--', old, new)
(repo / changed).write_bytes(b'after\n')
(repo / 'fresh space.txt').write_bytes(b'new\n')
raw = git('status', '--porcelain=v1', '-z', '--untracked-files=all',
'--find-renames=50%')
records = parse_status(raw)
assert len(records) == 3
assert (b'R ', new.encode(), old.encode()) in records
assert (b' M', changed.encode(), None) in records
assert (b'??', b'fresh space.txt', None) in records
print('records: 3; staged rename, unstaged edit, untracked file')
print('space, tab, newline and literal arrow paths preserved')
print('empty and malformed output checks passed')改名记录的顺序是目标在前、来源在后
零字节形式里不出现用于展示的箭头,改名的第一个路径是目标,第二个路径才是来源。解析器检测状态字段中的改名或复制标记后,必须再取一个字段;如果只是逐个零字节片段当成独立记录,来源路径就会被错误地当作另一行状态。
普通路径从第三字节之后开始,不应继续按空格拆分,因为后面的所有字节都属于路径。示例来源名故意含有箭头字样,目标名包含换行,证明这些字符可以只是文件名内容。输出中若保留了它们,解析就不再依赖视觉上看起来像分隔符的东西。
状态的两个位置分别回答不同的问题
对于本例的普通已跟踪文件,第一个状态位置描述索引相对于提交的变化,第二个位置描述工作区相对于索引的变化。代码断言改名是已暂存状态,另一个文件则是未暂存修改。双问号表示未跟踪,需要作为自身状态理解,不能机械套用普通修改的解释。
第一版 porcelain 的路径相对仓库根目录输出,不跟随用户的相对路径显示设置;颜色也被关闭。脚本仍应显式指定版本,避免未来更换为第二版时继续套用同一个解析器。第二版提供不同记录结构,解析方式必须同步调整,不能只改命令参数。
本文保留路径字节到最后,只在断言中对比预先知道的文件名。在实际工具里,需要访问文件时可以按平台约定转换;需要展示时则使用明确的转义表示。过早按某个编码严格解码,可能让原本合法但不符合该编码的路径把整次状态读取中断。
可解析快照不等于文件仍保持原样
命令指定显示全部未跟踪文件,避免目录摘要掩盖内部路径,并显式启用改名检测。改名是根据内容相似度识别的关系,不是文件系统永久记录的事件。样例使用内容完全不变的移动,使预期确定;真实仓库中同名、内容变化和阈值都会影响分类。
解析成功只说明当前输出被读懂,不能保证稍后文件状态没有变化。若下一步要批量处理,应再次确认目标,并通过参数列表传递路径,避免拼成 shell 命令。冲突和子模块还具有额外状态语义,扩展业务处理时应增加对应样本,而非把所有标记都统称为修改。


