
用终端看 Git 提交历史本来是个挺枯燥的事。直到我看到了 caveman 这个项目第一反应是“还能这么玩”它把每次 commit 渲染成一副 ASCII 风格的洞穴壁画每个提交者是一个小人坐在火堆旁边头顶上飘着提交信息。那种原始、粗粝、又带点幽默的视觉风格和“考古代码历史”这个场景意外地搭。我需要先说清楚 caveman 是什么它是一个开源的命令行工具借助 Python 和 Textual 构建运行在终端里通过解析 Git 仓库的提交记录把提交历史生成一幅纵向滚动的“岩画长卷”。开发者可以像考古学家一样一层一层地往下挖掘这个仓库的演进过程。如果你是喜欢折腾 CLI 工具、对终端 UI 设计感兴趣或者手头正好有个 Git 仓库想换个角度看历史的人这个项目会给你不少启发。我花了两周时间把它从“一个 demo”打磨到“敢在浏览器里跑全量测试”。这篇文章会把整个拆解过程、技术选型的逻辑、踩过的坑、以及最后怎么把性能压下来的经验完整记录下来。1. 项目立意与整体设计思路1.1 为什么选择“洞穴壁画”这种表达形式最早想做一个 Git 可视化工具时摆在我面前的有三条路Web 展示、GUI 桌面应用、终端 UI。Web 方案上限最高但需要搭服务、处理前端构建对一个只是想“换个角度看 commit history”的侧项目来说太重了。桌面 GUI 同样要处理跨平台打包。终端 UI 是性价比最优的中间态既保留了命令行用户的操作习惯又能在不离开终端的前提下做出足够惊艳的视觉效果。那为什么是“穴居人”而不是其他风格因为“caveman”这个隐喻和“Git 历史”存在天然的修辞对称每一次 commit 就像原始人在洞穴墙壁上留下的一个记号可能是打猎成功的记录也可能是一场失败的祭祀。把 commit 可视化成一个“篝火旁的小人”实际上是把软件开发这种高度抽象的活动降维成了一种原始、朴素、可感知的叙事场景。用户不需要理解 DAG、不需要看懂 rebase 的拓扑变化只需要看到一排小人坐在那里就能直观感受到这个仓库“有人住过、有人在干活”。这里关键的一点是视觉隐喻必须服务于信息传达不能只图好看。每个洞穴小人身上的颜色对应了不同的 Git 作者小人的尺寸反映了 commit 涉及改动文件数的量级火堆的明暗代表提交的时间远近。设计视觉元素时我坚持了一个原则——所有装饰性图案都必须承载至少一条可读的数据维度否则就砍掉。1.2 目标用户画像与场景边界我自己就是 caveman 的第一个目标用户长期泡在终端里的开发者习惯用git log --oneline --graph查看提交历史不满足于一行行单调的文字输出但又不愿意为此切换到浏览器。使用场景大概有这么几类代码审查之前快速扫描一段历史、给新同事介绍项目演进脉络、纯粹为了好玩把仓库 commit 历史截图发到群里。明确了用户画像之后功能边界就清晰了不需要支持 commit 的增删改操作不需要 diff 查看甚至不需要完整复刻 git log 的全部参数。caveman 的核心任务只有一个——把 Git 历史“装进一幅画里”然后用键盘上下浏览。所有设计决策都围绕这个核心任务展开凡是和它冲突的功能候选一律推迟到 v2。1.3 技术方案选型背后的取舍渲染方案上我对比过三个选项直接绘制 ANSI 色块、使用 Unicode 半块字符组合像素、纯 ASCII 字符拼图。ANSI 色块的精度最高但终端里无法做抗锯齿密集排列时视觉噪点严重。半块字符方案效果好但依赖终端对 Unicode 的渲染能力Windows 老版本终端会翻车。最后选了纯 ASCII ANSI 颜色的组合兼容性最好也最有“原始壁画”的味道——用、#、*、:这些字符的密度差来模拟岩石纹理的明暗过渡。框架层没有造轮子直接用 Textual。相比 Rich 的静态渲染Textual 提供完整的响应式布局、事件循环、焦点管理和滚动容器省了我至少一周的工作量。但 Textual 也有代价它基于rich的 Console 抽象底层大量依赖字符串拼接和正则替换对长列表的虚拟滚动支持得自己额外做。这就引出了性能优化的核心矛盾详见第 3 章。2. 核心功能拆解与关键技术点2.1 Git 数据解析层从根上控制信息量caveman 的第一步不是渲染而是把 git 仓库里的原始提交数据抽出来、清洗干净、丢掉不需要的信息。我直接调用git log命令并用自定义格式输出而不是用pygit2这类原生库。理由很现实git log是 git 自带的命令天然处理了各种边缘情况shallow clone、replace refs、unicode 文件名而绑定库反而需要单独维护兼容逻辑。核心命令是git log --all --dateiso-strict --format%H%x00%an%x00%ae%x00%ad%x00%s%x00--numstat这里用%x00分隔字段因为 NUL 字符是唯一不会出现在 commit message、作者名、文件名里的分隔符。用%x00而不是|或\t省掉了后续一堆转义解析的麻烦。--numstat用来拿每个 commit 增删行数这个数据是后续生成人物大小、火堆高度的依据。数据清洗阶段有两个必须处理的硬骨头。一是 commit message 里的 ANSI 转义序列有人会把带颜色输出的脚本结果直接 commit 进去必须用正则\x1b\[[0-9;]*m剥掉否则渲染时会把终端搞乱。二是非 UTF-8 编码的 commit message一些老仓库用 Latin-1解码时要用errorsreplace不能直接抛异常。2.2 文本布局引擎字符画里的“排版”拿到结构化数据之后进入布局阶段。这里先定义画布caveman 的每一“帧”是一行字符每个字符占据终端单元格的等高宽。因为终端单元格通常是宽高 2:1所以用字符拼人物时垂直方向天然会被压缩想在视觉上得到“圆润”的火堆原始字符矩阵就得纵向拉长。我设计了一套以 8 行高、11 列宽为基准的字符画模板描述一个人物头部用身体用#手臂用-和_腿用/、\。不同作者会用不同 ANSI 颜色渲染和#这样一眼就能看出来某个人的提交密度。布局上最难的部分是“火堆”和“信息气泡”的相对位置。火堆放在人物正前方信息气泡悬浮在人物上方气泡里的文字是 commit message 的截断版本。这里必须处理一个经典问题文字宽度不是字符数。中文字符在终端里占 2 列emoji 可能在 2 列也可能在 4 列长度计算必须用wcwidth库而不是 Python 的len()。否则中英文混排的 commit message 会把气泡撑破。气泡宽度按终端列数动态计算最宽不超过画布 60%。当 commit message 过长时不直接截断而是优先保留第一个句子按.、。、!、?切分再按剩余宽度做省略。这个细节体验差异很大直接按字数截断容易把核心语义切碎。2.3 交互设计键盘优先右键为辅caveman 的交互模型刻意做得极其简单方向键上下移动光标选择不同 commitEnter 键复制该 commit 的完整哈希空格键切换“作者过滤模式”q退出。没有鼠标操作没有快捷键面板因为这是一个“看历史的工具”不是编辑器。我用了 Textual 的ListView作为容器但自定义了内容渲染器每一行是一个“场景帧”整个 ListView 的高度自适应终端窗口。交互设计里最重要的一个细节是“惯性滚动”当窗口高度不足以显示全部历史时滚动条移动距离和内容行数之间要做平滑映射避免用户滚动时视角跳变。Textual 的ScrollView默认没有这个能力需要在mount()之后手动绑定scroll_up和scroll_down事件再配合scroll_to中间态实现缓动。这里特别想分享一个经验终端 UI 的交互反馈必须“轻”。使用 Textual 的BINDINGS定义键位时每个动作都要配一个notify或画面状态更新哪怕只是当前选中的 commit 高亮变化也要让用户感觉到“这个按键被接受了”。否则在字符画这种强视觉风格里用户很容易怀疑程序卡死了。3. 实操环节从零开始实现 caveman3.1 环境准备与依赖安装caveman 的开发环境是 Python 3.10、Textual 0.41、Git 2.30。前三者决定了程序能否运行最后一个git log命令的--format选项在所有主流版本里都稳定不必太担心兼容性。python -m venv .venv source .venv/bin/activate pip install textual rich pyperclippyperclip是后来加的因为跨平台复制文本到剪贴板这个功能自己写 Windows/macOS/Linux 三套实现太没必要。注意pyperclip在 Linux 上依赖xclip或xselREADME 里要写清楚。依赖装完之后第一件要做的事是跑通一个最小骨架输出一行字符画“小人”然后监听方向键上下移动。这个步骤虽然看起来无聊但能最快暴露终端环境问题——比如某些终端模拟器对 ANSI 真彩色的支持不完整颜色会失真。碰到这种情况降级到 256 色模式是可接受的方案。3.2 git log 数据采集与清洗直接上代码看核心解析函数我会附上每一步的思路说明。import re import subprocess ANSI_RE re.compile(r\x1b\[[0-9;]*m) def load_git_log(repo_path: str) - list[dict]: cmd [ git, -C, repo_path, log, --all, --dateiso-strict, --format%H%x00%an%x00%ae%x00%ad%x00%s%x00, --numstat, ] proc subprocess.run(cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace) raw_lines proc.stdout.splitlines() commits [] current None for line in raw_lines: if \x00 in line: if current: commits.append(current) parts line.split(\x00) current { hash: parts[0], author: ANSI_RE.sub(, parts[1]), email: parts[2], date: parts[3], message: ANSI_RE.sub(, parts[4]), files: 0, insertions: 0, deletions: 0, } elif line.strip() : continue else: # numstat 行: 新增\t删除\t文件名 numstat line.split(\t) if len(numstat) 3 and numstat[0].isdigit(): current[insertions] int(numstat[0]) current[deletions] int(numstat[1]) current[files] 1 if current: commits.append(current) return commits这段代码最需要留意的是--numstat输出里文件名的部分当文件名包含 tab 或换行符时git 会显示成 quoted 格式所以直接用split(\t)后取前三段是最稳的文件名本身被舍弃也不影响统计。如果你需要处理 merge commit--numstat默认不展开合并提交的 diff这里的信息量是刻意裁减过的——对可视化来说合并提交的“规模”远没有它的存在本身重要。数据清洗的另一个要点是日期解析。--dateiso-strict输出的格式是2025-01-02T15:04:0508:00直接从偏移量推导出 UTC 时间用于分组排序我不用任何日期解析库字符串前 10 位就是日期足够后续时间线聚类使用了。3.3 ASCII 人物与火堆的字符矩阵设计人物模板是字符画的核心资产直接决定视觉质量。我最初的模板长这样 ### --#-- / \ / \8 行 11 列的矩阵里人物看起来有点单薄。后来参考了几组 ASCII 艺术社区的设计改成了更饱和的版本有信心说观感提升了一个级别 ####### ####### ----#---- / # \ / # \ # \“头”是一行身体是 7 列宽度的#手臂穿透躯干腿部用斜线形成站姿。作者颜色只应用在上身体颜色统一用岩石灰ANSI 256 色 244这样既保留作者的辨识度又不会让多作者并排时颜色爆炸。火堆设计也经历了三轮迭代。最初是静态的^符号堆后来改成动态帧序列——4 帧循环每帧火焰高度不同帧切换间隔 400ms。动画效果不靠 Textual 的定时器而是在on_idle事件里更新时间戳只有当前可见区域需要重绘性能开销可以忽略。字符矩阵最终被编译成一个 Python 字典每个元素是(row_offset, col_offset, char, color)的元组渲染时遍历字典而不是重复拼接字符串。这里有个性能关键点把字符按列分组然后逐行.join(),比按行遍历反复拼接快了一个数量级。3.4 性能优化从“播放幻灯片”到“60 帧”初版跑起来后最直观的问题就是视觉风格拉满帧率也很感人大概 2 FPS。原因很快定位了——每个 commit 都重新渲染了一整幅完整画布没有做 Buffer 缓存和脏矩形管理。优化分三步走。第一步把字符画模板从“每帧重新拼接”改成“渲染完成后写入缓存”。因为人物模板是静态的只是颜色参数不同我做了 8 种预渲染缓存对应 8 种作者颜色。渲染 commit 时直接查缓存、拼背景省掉了大量字符循环。第二步引入可见区域裁剪。终端窗口可能只有 30 行高而仓库有几万行提交历史全量渲染显然浪费。Textual 提供viewport信息我可以算出当前滚动偏移量对应的 commit 索引范围只渲染这个范围内的场景帧。这里的计算逻辑要特别注意每帧场景高度是固定的 8 行所以start_idx scroll_offset // FRAME_HEIGHT对滚动越界做 clamp。第三步把插入符号cursor的移动频率从键盘事件直接刷新改为合并刷新。Textual 在滚动时本就会重绘内容区如果在on_key里同时触发 ListView 的layout和我的自定义重绘会造成重复渲染。把重绘逻辑挂到watch_scroll_offset的 watcher 上由 Textual 的响应式机制自动合并同一 tick 内的多次变更之后 FPS 稳定在 30 以上。以下是渲染主循环的核心骨架经过精简保留了关键逻辑from textual.app import App, ComposeResult from textual.widgets import Header, Footer, ListView, ListItem from textual.reactive import reactive class CaveCanvas(ListView): visible_start: reactive[int] reactive(0) def render_scene(self, commit, width, height): lines [[ for _ in range(width)] for _ in range(height)] # 1. 绘制洞穴背景(使用 . # 和空白模拟岩壁纹理) # 2. 绘制人物(查缓存) # 3. 绘制火堆(动态帧) # 4. 叠加提交信息气泡 return \n.join(.join(row) for row in lines) def watch_visible_start(self, old, new): self.refresh()实际发布时用户传入--limit参数可以限制渲染条数默认 500防止在超大型 monorepo 里误操作导致无响应。这个限制不是偷懒而是为用户好我在 README 里也写了原因——终端 UI 的沉浸感在500条提交之后就会衰减信息密度过载时应当按作者或分支过滤而不是无脑展示。3.5 终端宽度适配与字符对齐字符画天然对终端宽度敏感。在设计阶段我就定了所有可变形元素气泡宽度、壁画边框、底部信息栏都基于终端列数动态计算的原则。当终端宽度小于 80 列时需要折叠气泡模式commit message 从单行完整显示变成最多 20 列 ...。这里...不能直接拼必须用textwrap.shorten或者手动按显示宽度截断。为了兼容中文我用wcwidth写了一个辅助函数import wcwidth def truncate_by_width(text: str, max_width: int) - str: if wcwidth.wcswidth(text) max_width: return text result [] current_width 0 ellipsis_width 3 for char in text: char_width wcwidth.wcwidth(char) if current_width char_width ellipsis_width max_width: break result.append(char) current_width char_width return .join(result) ...这个函数在冒烟测试里发现的坑是emoji 的wcwidth返回值在不同 Linux 发行版上有差异有的返回 2有的返回 1。为了不在这上面纠缠最终做了一层降级——检测到未知宽度字符返回 -1 或 0 的时一律按 1 列处理宁可偶尔宽度不精确也不要溢出。4. 常见问题与排查实录4.1 渲染乱码终端字体和字符集双杀有用户反馈在 Windows Terminal 里人物身体变成了?。排查后确认是字体没覆盖#后面的 256 色块字符不是代码问题。这里要给所有字符画项目的开发者一个建议不要用█、▓、▒这类块元素字符做主渲染它们在老终端里极易变成豆腐块。caveman 只用 # * . _ - / \这 8 个 ASCII 字符任何字体、任何终端模拟器都不可能不支持。如果一定要用 Unicode 装饰性字符比如气泡边框的圆角╭╮必须在 README 里用表格明确列出推荐字体列表包括 Nerd Font、Fira Code、Sarasa Term SC以及微软的 Cascadia Mono。我在 README 里把这一步写了三遍还是有用户不看。所以程序里又加了一层自检启动时检测当前字符集的渲染宽度是否正常异常时在启动横幅里给出告警提示。4.2 仓库太大导致卡死虚拟滚动还不够有用户拿一个 5 万 commit 的 monorepo 来测程序直接 OOM。原因不是渲染内存爆了而是git log的--all会扫描所有分支引用输出几十万行文本光解析就把内存吃满。修复策略是做成“懒加载分页”。最外层加了一层先执行git rev-list --count --all拿到总提交数再按每 1000 条为一块用git log --skipN -n 1000分段取数据。配合--limit的默认值把首屏加载时间控制在 500ms 以内。这个改法直接影响了下层设计——原先一次性构建的commits列表必须改造成一个带load_more()方法的迭代器。Textual 的虚拟滚动在这种场景下才真正发挥优势虽然数据总量很大但渲染层只需要保持当前视口和上下各 3 帧的缓存。4.3 地面纹理导致分心再次强调克制严格来说这不算 Bug而是“隐形决策”。第一版洞穴背景我画得太满——除了人物和火堆四周还填满了#、%、模拟岩石起伏和青苔体积感是有了但真实观感极其拥挤字符噪点严重。后来我重写了背景算法只保留离人物边缘至少 3 列的留白区背景字符密度按距离递减靠近壁画中心的区域背景字符出现概率低于 20%并且只使用.和 两个字符。这个细节让整个画面的“呼吸感”上来了。如果读者也在做字符画渲染我的建议是背景图案宁可单调不要丰满因为用户注意力应当集中在数据本身。4.4 测试策略CLI 工具到底怎么测caveman 的正确性验证主要分三层。第一层是单元测试主要覆盖数据解析、宽度截断、字符矩阵翻转这些纯函数。第二层是快照测试我在 fixture 目录放了一个 20 次提交的小仓库用rich的Console(recordTrue)导出 SVG 格式渲染结果比对哈希值。这个测试的价值在于任何布局变动都能在 CI 里第一时间被捕获。第三层是手动冒烟测试因为终端 UI 有很多环境相关的行为光标闪烁、宽字符重绘、滚动边界依赖真实设备自动化测不到。我还写了一个特殊的“退化测试”在只有 1 个 commit 的新仓库和 0 个 commit 的空仓库里程序必须优雅退出不能抛index out of range。这类边界情况是用户最容易遇到且体验最差的场景值得单独花时间处理。5. 经验沉淀与后续扩展方向5.1 字符画世界里那些看不见的功夫做 caveman 的最大收获不是学会了某个框架而是真正理解了“终端是一种约束严格的设计媒介”。在 Web 里一片 CSS 就搞定的布局在终端里必须手动处理每个字符的宽度、颜色和坐标偏移。这反而训练了一种刻意精简的设计直觉即一切非必要视觉元素都是噪声真正要传达的信息密度才是最值得分配的资源。另一个体会是关于“工具类小项目”的定位维护。caveman 不像大型框架需要讨好所有用户它允许自己是一个“偏科的玩具”只要把一个场景做深做透自然会有用户为这种鲜明风格买单。我后续收到的大量 Issue几乎都是围绕视觉样式和终端兼容性的基本没有人要求给它加一个git push功能。这说明产品边界清晰不仅对维护者友好也让用户形成稳定预期。5.2 路线图里最值得尝试的三个方向从我维护这个项目半年多来的实际反馈看下一步最值得做的三个扩展是一是支持将渲染结果导出为 SVG 或 PNG方便用户分享到网页和社交媒体这会大幅提升项目传播度二是增加基于时间轴的“快进”模式让用户像看动画一样浏览一个仓库从首次提交到现在的发展脉络这个我目前已经实现了半成品主要卡在性能上三是增加基于作者维度的统计视图用不同颜色标记 commit 密度直接输出一份“团队协作热力图”。如果读者也想基于 Git 数据做个终端可视化小项目我的建议很明确先抄 caveman 的最小路径——用git log拿结构化数据、用 Textual 搭骨架、用 ASCII 做输出跑通一个“粗糙但能玩”的版本再逐步替换成自己的设计语言。不要一开始就在渲染引擎上较劲因为终端 UI 的特殊性会逼迫你在迭代中理解它的所有约束这比任何提前规划都行之有效。