首页
/ lazygit 中的伪终端实战:以 creack/pty 库为核心驱动彩色 Diff 与自定义 Diff Renderer

lazygit 中的伪终端实战:以 creack/pty 库为核心驱动彩色 Diff 与自定义 Diff Renderer

2026-09-04 20:24:45作者:田桥桑Industrious

lazygit 是一款运行在终端里的 Git 图形界面,而它要向子进程(git、diff 渲染器、自定义命令)输出彩色高亮和按列宽换行的内容,就离不开 Unix 伪终端(pseudo-terminal,pty)技术。本篇以仓库中 vendor 的 creack/pty 库文档 为主体,完整讲解该库的安装方式与两种典型用法(命令行示例、Shell 转发示例),再结合 lazygit 源码展示这个库是如何被封装、调用,最终支撑起 "用 pty 渲染 git diff" 这条完整链路的。读完后,你将掌握 pty 库的核心 API(pty.Startpty.StartWithSizeSetsizeInheritSize),并能看懂 lazygit 从 GUI 视图尺寸到 pty 子进程窗口大小的端到端传递过程。

什么是 pty:为什么 lazygit 必须用伪终端

creack/pty 的包文档 看,这个库的定位很明确:

Package pty provides functions for working with Unix terminals.

pty 是一对特殊的文件描述符:master 端(ptmx)供父进程读写,slave 端(tty)被当作子进程的 stdin/stdout/stderr。关键在于,子进程认为自己连接的是"真"终端——git 会因此启用颜色输出、尊重 GIT_PAGER,diff 工具会按终端宽度换行。这正是 lazygit 需要的行为。lazygit 源码中 pkg/gui/pty.go 的注释把动机说得非常直白:

// Some commands need to output for a terminal to active certain behaviour.
// For example, git won't invoke the GIT_PAGER env var unless it thinks it's
// talking to a terminal. We typically write cmd outputs straight to a view,
// which is just an io.Reader. the pty package lets us wrap a command in a
// pseudo-terminal meaning we'll get the behaviour we want from the underlying
// command.

换言之:如果把 git 的输出直接接到 io.Pipe 上,git 会认为自己在对"非终端"说话,从而关闭颜色、跳过 pager 过滤;而把命令包进 pty,lazygit 就拿到了完整的终端行为。

当前仓库锁定的版本是 go.mod 中的 github.com/creack/pty v1.1.24,源码整体被 vendor 在 vendor/github.com/creack/pty 目录下,按平台拆分为 pty_linux.gopty_darwin.gopty_freebsd.go 等多个实现文件。

安装与基本 API

README 给出的安装命令是:

go get github.com/creack/pty

对于本仓库这样的 vendor 模式项目,依赖已经固化在 vendor/github.com/creack/ptyvendor/modules.txt(其中记录了 # github.com/creack/pty v1.1.24)中,无需额外操作即可引用。

库的核心 API 可以分为三类:

  1. 打开 pty 对pty.Open() 返回 master(pty)与 slave(tty)两个 *os.File,定义见 doc.go
  2. 在 pty 中启动命令pty.Start(cmd) 会把 cmd 的 stdin/stdout/stderr 接到 slave 端、启动进程,并返回 master 端的 *os.File
  3. 尺寸管理Winsize 结构(Rows/Cols/X/Y 四个 uint16 字段)配合 Setsize(通过 ioctl(TIOCSWINSZ) 调整大小)、Getsize/GetsizeFull(读取尺寸)与 InheritSize(把源终端尺寸套用到 pty),实现位于 winsize.gowinsize_unix.go

另外需要注意 README 中那句重要的生产环境提示:示例中的 *os.File 若希望 SetDeadline 生效、Close() 能中断阻塞中的 Read(),需要手动调用 syscall.SetNonblock——返回的 master 端文件默认并非非阻塞模式。

示例一:在 pty 中运行命令并注入输入

README 的第一个示例展示了最小闭环:用 pty.Start 启动 grep --color=auto,向 master 端写入输入数据,再把输出复制回 stdout:

package main

import (
	"io"
	"os"
	"os/exec"

	"github.com/creack/pty"
)

func main() {
	c := exec.Command("grep", "--color=auto", "bar")
	f, err := pty.Start(c)
	if err != nil {
		panic(err)
	}

	go func() {
		f.Write([]byte("foo\n"))
		f.Write([]byte("bar\n"))
		f.Write([]byte("baz\n"))
		f.Write([]byte{4}) // EOT
	}()
	io.Copy(os.Stdout, f)
}

这里有三个值得注意的细节:

  • fmaster 端:写 f 等于向子进程喂 stdin,读 f 等于取子进程的合并 stdout+stderr。grep --color=auto 只有在检测到"终端"时才输出 ANSI 颜色,这就是 pty 的价值;
  • 末尾写入的 []byte{4} 是 EOT 控制字符(Ctrl-D),用来结束 shell/命令的输入流;
  • README 同时声明:这些示例仅用于演示,不是生产级实现。

pty.Start 的实现看(run.go),它只是 StartWithSize(cmd, nil) 的快捷方式;真正干活的是 StartWithAttrs:先 Open() 得到 pty/tty 对,若指定了尺寸则先 Setsize,再把 cmd.Stdoutcmd.Stderrcmd.Stdin 中为 nil 的项统一接到 slave 端,最后 cmd.Start() 并返回 master 端。而 start.go 中的 StartWithSize(非 Windows 平台)还会设置:

cmd.SysProcAttr.Setsid = true   // 子进程成为新会话的领导者
cmd.SysProcAttr.Setctty = true  // 把该 pty 设为控制终端

在 Linux 上,Open() 的底层实现(pty_linux.go)则是标准的 devpts 流程:打开 /dev/ptmxioctl(TIOCGPTN) 查出 /dev/pts/N 路径 → TIOCSPTLCK 解锁 → 以 O_NOCTTY 打开 slave 端。

示例二:交互式 Shell 转发(含 SIGWINCH 尺寸同步)

README 的第二个示例是一个完整的交互式终端转发器,它额外演示了 pty 尺寸同步 这一生产级必备能力——每当窗口大小变化(SIGWINCH 信号)时,把父终端的尺寸继承给 pty:

package main

import (
	"io"
	"log"
	"os"
	"os/exec"
	"os/signal"
	"syscall"

	"github.com/creack/pty"
	"golang.org/x/term"
)

func test() error {
	// Create arbitrary command.
	c := exec.Command("bash")

	// Start the command with a pty.
	ptmx, err := pty.Start(c)
	if err != nil {
		return err
	}
	// Make sure to close the pty at the end.
	defer func() { _ = ptmx.Close() }() // Best effort.

	// Handle pty size.
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGWINCH)
	go func() {
		for range ch {
			if err := pty.InheritSize(os.Stdin, ptmx); err != nil {
				log.Printf("error resizing pty: %s", err)
			}
		}
	}()
	ch <- syscall.SIGWINCH // Initial resize.
	defer func() { signal.Stop(ch); close(ch) }() // Cleanup signals when done.

	// Set stdin in raw mode.
	oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
	if err != nil {
		panic(err)
	}
	defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // Best effort.

	// Copy stdin to the pty and the pty to stdout.
	// NOTE: The goroutine will keep reading until the next keystroke before returning.
	go func() { _, _ = io.Copy(ptmx, os.Stdin) }()
	_, _ = io.Copy(os.Stdout, ptmx)

	return nil
}

func main() {
	if err := test(); err != nil {
		log.Fatal(err)
	}
}

这个示例的要点可以拆成四层:

  1. 尺寸同步signal.Notify(ch, syscall.SIGWINCH) 监听窗口变化,pty.InheritSize(os.Stdin, ptmx) 读取父终端尺寸并 Setsize 到 pty(实现见 winsize.go,内部就是 GetsizeFull(pty) + Setsize(tty, size));注意 ch <- syscall.SIGWINCH 这行会主动触发一次"初始 resize";
  2. 原始模式term.MakeRaw 把父终端设为 raw 模式,保证按键原样透传给子 shell,退出时用 term.Restore 恢复;
  3. 双向拷贝:一个 goroutine 负责 stdin → pty,主 goroutine 负责 pty → stdout,主协程在 pty 端读到 EOF 后退出;
  4. 资源清理defer ptmx.Close() 关闭 master 端。README 特别强调"确保最后关闭 pty",这与库内 StartWithAttrs 中"尽力关闭"(best effort)的注释风格一致。

这个 SIGWINCH 同步思路在 lazygit 里同样存在,只是驱动方式换成了 TUI 框架的布局回调,下面详述。

lazygit 如何封装 pty:平台无关的 Pty 接口

lazygit 并没有直接使用 pty.Start,而是在 pkg/commands/oscommands/pty.go 中定义了一个平台无关的接口:

// Pty is the master side of a pseudo-terminal running a subprocess. The
// concrete implementation is platform-specific: creack/pty on Unix and
// ConPTY on Windows.
type Pty interface {
	io.ReadWriteCloser
	Resize(cols, rows uint16) error
}

它把 pty master 端抽象成"可读写 + 可改尺寸的流",并返回 StartedPty 结构(含 Pty、子进程句柄 Process、带 *exec.Cmd.Wait 语义的 Wait 函数)。这一抽象的动机从 pty_windows.go 的注释里可见一斑:Windows 上没有 Unix pty,ConPTY 通过 CreateProcess 直接派生进程,cmd.Process 会是 nil,必须单独暴露进程句柄。

Unix 侧的实现(pty_unix.go)就是本文主角 creack/pty 的直接消费者:

func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) {
	f, err := creackpty.StartWithSize(cmd, &creackpty.Winsize{Cols: cols, Rows: rows})
	if err != nil {
		return StartedPty{}, err
	}
	return StartedPty{
		Pty:     &unixPty{master: f},
		Process: cmd.Process,
		Wait:    cmd.Wait,
	}, nil
}

对照 README 的 Shell 示例,可以清楚看到 API 的对应关系:

场景 API
启动命令并指定初始窗口尺寸 creackpty.StartWithSize(cmd, &Winsize{...})(lazygit 用法)
简单启动(不指定尺寸) pty.Start(cmd)(README 两个示例均用此)
运行中调整尺寸 creackpty.Setsize(master, &Winsize{...}),即 unixPty.Resize 的实现
从另一终端继承尺寸 pty.InheritSize(README Shell 示例)

值得一提的是 TerminateLivePtys 的平台差异:在 Unix 上它是空操作(pty_unix.go),因为关闭 master 端会向子进程发 SIGTERM(前台进程组还会收到 SIGHUP),进程会自行清理;而 Windows 版需要 job object 级联杀进程树并回收 conhost,实现完全不同——这正是把平台差异隔离在 oscommands 层、让 GUI 层只面对 Pty 接口的好处。

lazygit 的调用链:从视图尺寸到 pty 子进程

lazygit 使用 pty 的典型场景是 diff 渲染:用户在配置中启用自定义 diff renderer(如 delta、git-difftool 等)后,lazygit 会把 git diff 之类的命令包进 pty 执行,让渲染器按终端宽度换行、输出高亮。核心逻辑在 pkg/gui/pty.gonewPtyTask,整条链路如下。

1. 只有关闭了原生渲染器才启用 pty。 newPtyTask 开头做了一个短路判断:

if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
	// If we're not using a custom diff renderer, then we don't need to use a pty
	return gui.newCmdTask(view, cmd, prefix)
}

即 diff renderer 为 rawGit 时直接走普通管道任务,不创建 pty。

2. 尺寸计算在 UI 线程完成。 代码先在 UI 线程调用 gui.desiredPtySize(view)(即 view.InnerSize()pty.go)取得视图内宽内高,再放入 afterLayout 回调中创建任务。注释解释了原因:task 的 start 函数运行在独立 goroutine 上,不能在布局过程中读视图的实时尺寸;而 pty 必须在布局之后启动,才能拿到正确的初始尺寸——这与 README Shell 示例中"先确定尺寸再运行"的思路一致。

3. 环境变量注入:让 diff renderer 认为自己在哑终端里。 启动 pty 前,lazygit 会:

cmd.Env = removeExistingTermEnvVars(cmd.Env)
cmd.Env = append(cmd.Env, "TERM=dumb")
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)

removeExistingTermEnvVarspty.go)会剔除 TERMTERM_PROGRAMTERMINAL_EMULATOR 等一整套终端标识变量,然后统一设 TERM=dumb——源码注释说明这是"告诉 diff renderer 我们是一个非常简单的终端,不要使用移动光标、清屏、查询颜色等高级能力";同时 LAZYGIT_COLUMNS 环境变量(newPtyTask 开头设置)用于那些无法直接查询终端宽度的渲染脚本。

4. pty 尺寸随视图联动。 每个启动的 pty 都会按视图名注册进 gui.viewPtmxMap;当布局变化时,onResizepty.go)遍历该 map,对每个 pty 调用 p.Resize(cols, rows)——在 Unix 上落到 creackpty.Setsize。这就是 README 中 SIGWINCH + InheritSize 模式在 TUI 里的等价实现:只是触发源从操作系统信号换成了 gocui 的布局回调。源码中留有一条 TODO,诚实地标注了当前限制:"handle resizing properly: we need to actually clear the main view and re-read the output from our pty. Or we could just re-run the original command from scratch"。

5. 失败降级为管道任务。oscommands.StartPty 返回错误,start 闭包不会让整个功能崩溃,而是回退到 startCmdWithPipe:牺牲 diff renderer,但至少把命令输出画进视图。这是一个值得借鉴的降级设计。

6. 平台特判:Windows 上的 git 索引锁保护。 withPtyGitConfig 揭示了 pty 生命周期管理引发的一个真实问题:在 Windows 上,task 停止会销毁伪控制台,git 的进程可能在任意执行点被 ExitProcess 杀死;若恰好发生在 git diff 结束时自动刷新索引(diff.autoRefreshIndex 默认开启,会短暂持有 index.lock)的窗口,就会留下陈旧的 index.lock 让下一条 git 命令报错。因此 lazygit 会给直接调用的 git 命令注入 -c diff.autoRefreshIndex=false;而 Unix 上停止 pty 子进程走 SIGTERM,git 的信号处理器会自己清理锁文件,所以无需禁用——这段注释同时印证了 pty_unix.go 中"master 关闭发 SIGTERM/SIGHUP"的说法。

小结:从 60 行示例到生产级链路

回到 creack/pty 的 README,这份不到 100 行的文档其实给出了使用伪终端的两个标准范式:

  • Command 示例回答"如何把一条命令包进 pty 并交换数据"——pty.Start + 读写 master 端即可,lazygit 的 StartPtypty_unix.go)正是这一范式的工程化版本,只是补上了 StartWithSize 的初始尺寸与接口封装;
  • Shell 示例回答"如何保持 pty 与真实终端尺寸同步"——SIGWINCH + InheritSize,lazygit 则以 onResize + Setsize 的回调形式实现了同样的语义(pkg/gui/pty.go)。

两条示例都反复强调的两点——master 端 *os.File 需手动 Close() 清理、非阻塞读取要手动 SetNonblock——在 lazygit 中分别体现为 onClose 回调中的 p.Close()viewPtmxMap 的删除,以及把 pty 读入交给 tasks.CmdTask 的 scanner 循环处理。理解了这些,就理解了 lazygit 的彩色 diff 面板、自定义 diff renderer 乃至 LAZYGIT_COLUMNS/TERM=dumb 这些配置背后共同的地基:一对 master/slave 文件描述符,和一个把它们变成"真终端"的 ioctl 世界。

(注:本文事实均出自当前仓库——vendored 库版本 v1.1.24 以 go.modvendor/modules.txt 为准;README 示例为库作者提供的演示代码,生产环境使用需按上文注意事项自行补强。)

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384