# gyx **Repository Path**: graviton/gyx ## Basic Information - **Project Name**: gyx - **Description**: git 一下. 管理多个 git repos 的状态. 参考了 https://github.com/nosarthur/gita - **Primary Language**: Unknown - **License**: AGPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-17 - **Last Updated**: 2026-10-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # gyx **git 一下看看** —— 一次看完所有 git 仓库的状态, 并把 git/shell 命令批量派发下去. 用 Go 重写自 [gita](https://github.com/nosarthur/gita), 单个静态二进制, `scp` 过去就能用. ``` $ gyx ll dotfiles master [] chore: 补上 zsh 的补全配置 (3 weeks ago) parser master [*↑] chore: 依赖升级, lint 进门禁 (19 hours ago) solver 29wt master [$] Merge branch 'release/2.1' (1 year, 4 months ago) toolbox feat/deterministic-sum [*∅] fix: 求和改用逐位可复现的顺序 (6 weeks ago) webapp master [↑] feat(deploy): 部署布局改成与仓库同构 (10 hours ago) ``` ## `gyx ll` 怎么读 ``` solver 29wt master [*+$?↑] fix the thing (2 days ago) │ │ │ │ │ └ 最后一次提交的相对时间 │ │ │ │ └ 最后一次提交的标题 │ │ │ └ 状态符号, 见下 │ │ └ 当前分支; detached HEAD 时显示 commit 的前 7 位 │ └ 挂了几个 linked worktree(一个都没有时这列不出现) └ 仓库名 ``` ### 方括号里的状态符号 顺序固定: **工作区 → 暂存区 → 贮藏 → 未跟踪 → 与远端的关系**. 前四个各自独立, 有就出现: | 符号 | 含义 | 对应 `git status` 里的 | |:---:|---|---| | `*` | 工作区有未暂存的改动 | Changes not staged for commit | | `+` | 暂存区有改动 | Changes to be committed | | `$` | 有 stash | (`git stash list` 非空) | | `?` | 有未跟踪的文件或目录 | Untracked files | 第五位是本地与远端的关系, 五选一, 和**分支名的颜色**是同一件事的两种表达: | 符号 | 颜色 | 含义 | 该干什么 | |:---:|---|---|---| | (空) | green | 与远端一致 | 没事 | | `↑` | purple | 本地领先远端 | 可以 push | | `↓` | yellow | 本地落后远端 | 可以 pull / merge | | `⇕` | red | 两边都有对方没有的提交 | 得 rebase 或 merge | | `∅` | white | 没有可用的远端分支 | 没设 upstream, 或 detached HEAD | 读几个例子: | | | |---|---| | `[]` | 干净, 且与远端一致 | | `[*↑]` | 有未暂存的改动, 且本地领先 | | `[+?∅]` | 有已暂存的改动和未跟踪文件, 没有远端 | | `[*+$?⇕]` | 全中 | 紫色表示领先、黄色表示落后是沿用 gita 的用意: 以 green 作基准, 借[蓝移/红移](https://en.wikipedia.org/wiki/Blueshift)表示"冲过头了/落在后面". 符号和颜色都能改, 见 `gyx color` 和配置里的 `[settings.symbols]`. 输出不是终端(管道、重定向)或设了 `NO_COLOR` 时自动不上色, 不用加 `-C`. ### worktree 那一列 `29wt` 表示这个仓挂了 29 个 linked worktree, 默认折叠起来不占行. `gyx ll -w` 展开: ``` solver master [$] Merge branch 'release/2.1' (1 year, 4 months ago) ├ hotfix release/2.0 [] Merge branch 'fix/overflow' (5 months ago) ├ next dev [↓] Merge branch 'feat/sparse' (3 months ago) └ spike spike/gpu [*] feat: 试一下 GPU 后端 (3 weeks ago) ``` ⚠️ `$` 只出现在主仓那一行. `refs/stash` 位于 commondir, 同一个主仓的所有 worktree **共享一个 stash**, 在 30 行上都标 `$` 只是噪音. ### 两种特殊行 ``` fresh master [?∅] # 还没有任何提交: 分支照常显示, 提交那两列是空的 gone ! not a git repository: /src/gone # 路径没了或读不了, 整行显红 ``` ## 相比 gita 的改动 | | gita | gyx | |---|---|---| | 部署 | pip 装 Python 包 | 单个 3.7 MB 静态二进制 | | 每仓 git 子进程 | 7–9 个 | **2 个** | | worktree 的 stash | 永远检测不到([源码里的 FIXME](https://github.com/nosarthur/gita/blob/master/gita/info.py)) | 正确 | | 多 worktree 的仓 | 要么不管, 要么 30 行刷屏 | 折叠成 `29wt`, `-w` 展开 | | 列宽 | 固定 10/18 字符, 长分支名会错位 | 按数据动态计算 | | 中文提交信息 | 按字符数算宽度, 表格错位 | 按终端列宽算, 对齐 | | 配置 | 5 个 CSV + 1 个 JSON, 且会被覆写掉注释 | `config.toml`(你的, 只读)+ `state.toml`(程序的) | | 管道输出 | 始终带颜色, 要手动 `-C` | 非 TTY / `NO_COLOR` 自动关色 | ### worktree 是重点 gita 的 `has_stashed()` 直接判断 `/.git/logs/refs/stash` 是不是文件. 在 linked worktree 里 `.git` 是**文件**不是目录, 这条路径永远不存在, `$` 标记永远不亮: ``` $ git -C ~/worktrees/next stash list stash@{0}: WIP on dev: 312f3061 half-finished refactor $ gita ll # 漏了 $ next dev [↓] $ gyx ll # 对 next dev [$↓] ``` gyx 通过 `.git` 文件 → `gitdir` → `commondir` 这条链解析出共享的 git 目录, 全程纯文件 I/O, 零子进程. ⚠️ `refs/stash` 位于 commondir, 也就是**同一主仓的所有 worktree 共享一个 stash**. 所以 `gyx ll -w` 只在主仓那一行显示 `$`, 不在 30 个 worktree 行上重复. ### 为什么只要 2 个子进程 ```sh # 一条命令同时给出 分支 / upstream / ahead-behind / staged / unstaged / untracked git status --porcelain=v2 --branch --untracked-files=normal --no-renames -z # 一条命令同时给出 提交信息 / 相对时间 / ISO 时间 git log -1 --format=%s%x00%cr%x00%cI ``` 本地/远端的 5 种状态纯从 `# branch.ab` 头推出, 不需要 `merge-base` + 三次 `diff --quiet`: | `# branch.ab` | 状态 | 颜色 | |---|---|---| | 该行不存在 | `no_remote` | white | | `+0 -0` | `in_sync` | green | | `+N -0` | `local_ahead` | purple(可以 push) | | `+0 -M` | `remote_ahead` | yellow(可以 merge) | | `+N -M` | `diverged` | red | detached HEAD 直接用 `# branch.oid` 的前 7 位, 省掉 `git describe`. stash 和 worktree 发现走文件 I/O, 也不花进程. 实测 48 个仓(含 29 个 worktree): gita 0.250 s / 2377 个 git 进程, gyx 0.071 s / 102 个. ## 安装 打了 tag 的版本由 CI 跨 6 个平台(linux/darwin/windows × amd64/arm64)编译好, 发布在内网 GitLab 的 Generic Packages 里, 每个包内含二进制、README、LICENSE 和三套 shell 补全脚本. 解压把 `gyx` 丢进 `PATH` 就能用. 自己编: ```sh git clone https://gitee.com/graviton/gyx && cd gyx go run build.go # gofmt + vet + test, 然后编本机 → ./build/gyx go run build.go -all # CI 那六个发布目标 go run build.go -t linux/amd64 -t darwin/arm64 # 只要某几个 go run build.go -vet=false -test=false # 只想快点出个产物 ``` `build.go` 是只用标准库的单文件, 不需要先编译它, 三个平台同一条命令; **vet 与 test 默认是开的**(和老 `build.sh` 一致). 版本号按 `git describe --tags --always --dirty` 编进二进制(`-X …/internal/cmd.Version`), 和 CI 打包用的是同一个变量; `-ver` 可以手动 指定. 产物命名: `linux/amd64` → `build/gyx`(deploy.go 按这个名字读), `windows/amd64` → `build/gyx.exe`, 其余 → `build/gyx--[.exe]`. 旧的 `build.sh` / `build.nu` 已挪到 `backup/` 备用, 不要复活. 装到本机(`/data/cmbin` 共享工具目录 + `~/bin`): ```sh go run deploy.go # -n 预演; -t DIR 换目标; -binary 指定别的产物 ``` 它比 `cp` 多做三件事: 先验产物是不是 Linux x86-64 ELF(`/data/cmbin` 整棵树会被 `chmdply push --task cmbin` 分发到十几台机器); 写入走临时文件 + 原子改名(目标正被 执行时 `cp` 会 `ETXTBSY`); 内容相同则不动. 补全: ```sh gyx completion bash > /etc/bash_completion.d/gyx gyx completion zsh > "${fpath[1]}/_gyx" gyx completion fish > ~/.config/fish/completions/gyx.fish ``` ## 用法 ```sh gyx add ~/src/* # 注册仓库 gyx add -n ~/src/* # 先看看会加哪些, 不真的写 gyx add -r ~/src # 递归找, 进了仓就不再往下钻, 并跳过 linked worktree gyx add -a ~/src # 递归 + 按目录层级自动建组 gyx add -g work ~/proj/foo # 加进 work 组 gyx add -b ~/mirrors/x.git # 收 bare 仓 gyx ll # 看全部 gyx ll -w # 展开 worktree gyx ll -g # 按组分块 gyx ll work # 只看 work 组 gyx wt solver # 只看某个仓的 worktree gyx ls # 列出仓名, 一行一个 cd $(gyx ls solver) # 跳到某个仓 ``` `gyx add` 会逐个检查再收, 不是把参数照单全收: | 目标 | 结果 | |---|---| | 普通仓(`.git` 是目录) | ✅ 收 | | linked worktree(`.git` 是文件) | ✅ 收(直接点名时)| | bare 仓 | 仅 `-b` 时收 | | submodule | 收; `-s` 可跳过 | | **`.git` 指向的目标不存在**(未初始化的 submodule、已 prune 的 worktree) | ⏭ 跳过 | | 普通目录 / 空目录 / 路径不存在 | ⏭ 跳过, stderr 提示一行 | | 已经注册过的路径 | ⏭ 跳过 | 所以 `gyx add ~/src/*` 这种一把梭是安全的 —— 不是仓的会被逐条报出来然后跳掉, 不会让整条命令失败: ``` $ gyx add ~/src/* gyx: /home/me/src/notes is not a git repo, skipped gyx: /home/me/src/tmp is not a git repo, skipped Added 12 repo(s). ``` 重名会自动消歧: 第二个 `src` 会记成 `proj/src`(拿父目录名限定). `-r` / `-a` 递归时: - **进了仓就不再往下钻** —— vendor、submodule 不会被顺带扫进来 - **跳过隐藏目录** —— `gyx add -a ~` 不会把 `~/.local/share/nvim/lazy/*` 的几十个插件、 `~/.cache`、`~/.cargo` 里的东西全卷进来; 要的话加 `--hidden`. 把隐藏目录**当搜索起点**不受影响(`gyx add -r ~/.config` 照常能用) - **跳过 linked worktree** —— 它们会通过主仓自动发现, `-w` 就能看到; 真想当独立仓收进来用 `--include-worktrees` ### 清理 ```sh gyx prune # 删掉失效的条目(就是 ll 里打 ! 的那些) gyx prune -n # 先看看会删哪些 gyx rm --under ~/.local # 按路径前缀批量删, 递归加多了的快速回退 gyx rm --under ~/.local -n ``` `gyx prune` 删的是**路径没了**或 **`.git` 已经不可用**的条目, 判据和 `ll` 打 `!` 完全一致. 最常见的来源是未初始化的 submodule: 用了 submodule 的项目打包发布时经常 只带 `.git` 文件而不带它指向的 `.git/modules` 目录, git 对这种会报 `fatal: not a git repository: (null)`. 两个命令都不动磁盘, 只改 gyx 自己的清单. 批量派发命令: ```sh gyx pull # 内置的 git 委派命令, 见 gyx --help gyx fetch work # 只对 work 组 gyx super repo1 repo2 checkout -b feature gyx shell 'du -sh .git' ``` 分组与上下文: ```sh gyx group add repo1 repo2 -n work -p ~/work # 建组 gyx group # 列出所有组及成员 gyx group rmrepo repo2 -n work # 从组里移掉 gyx group rename work job gyx context work # 之后所有不带仓名的命令都只作用于 work 组 gyx context auto # 按当前目录自动选组 gyx context # 看当前上下文 gyx context none # 清掉 gyx rename old new # 改名, 分组成员和 context 会跟着更新 gyx clear --yes # 清空(--yes 是防误删的) ``` 显示配置: ```sh gyx color # 看调色板和当前方案 gyx color set in_sync b_green gyx color reset gyx info # 看启用/未启用的列 gyx info add path gyx info rm commit_msg gyx info set-length commit_msg 60 # 0 = 自动 gyx flags set myrepo --git-dir=/srv/repo.git --work-tree=/srv/tree gyx flags # 看哪些仓有自定义 flags ``` 搬家与迁移: ```sh gyx freeze > repos.csv # 导出 URL / 名字 / 路径 / 分支 / 分组 gyx clone -f repos.csv # 在新机器上克隆回来 gyx clone -p -f repos.csv # 克隆回原来的路径 gyx import-gita # 从已有的 gita 配置导入 gyx import-gita -n # 先看看会导入什么 ``` `freeze` 的格式和 gita 的一致, 两个工具的文件可以互相读写. 单仓、或 `log`/`show`/`difftool` 这类要霸占终端的命令会串行执行并接管 tty. 其余并发跑, 且 **stdin 接到 /dev/null**(避免多个仓同时抢密码输入); 失败的仓会**再串行重跑一遍**并继承 stdin, 这样密码提示能正常回答. ## 配置 配置分两层, 都在 `~/.config/gyx/`(优先级: `GYX_PROJECT_HOME` > `XDG_CONFIG_HOME` > `~/.config`): | 文件 | 归谁 | 说明 | |---|---|---| | `config.toml` | **你** | gyx 只读不写, **注释、格式、键序原样保留**. 首次运行会生成一份全部注释掉的模板. | | `state.toml` | **gyx** | `gyx add` / `group` / `context` 这类命令写这里. 不保留注释. | `state.toml` 叠在 `config.toml` 上, 且**只记录与 `config.toml` 不同的部分**. 这样既不用处理 TOML 注释的保留问题, 也不会让程序写的值悄悄遮住你手写的值. ```toml # ~/.config/gyx/config.toml —— 你手写的, 随便加注释 [settings] jobs = 12 # 并发上限, 0 = 自动 context = "" # 当前分组上下文; "auto" 按 cwd 推断 info = ["branch", "commit_msg", "commit_time"] expand_worktrees = false [settings.colors] in_sync = "b_green" # 默认 green 在我终端上太暗 # 还有 no_remote / diverged / local_ahead / remote_ahead [settings.symbols] dirty = "*"; staged = "+"; untracked = "?"; stashed = "$" [settings.layout] # 0 = 不限宽; 列宽本来就按数据自动算, 这里只是上限 commit_msg = 0 [cmd.pull] # 覆盖或新增委派命令 cmd = "git pull --rebase" help = "rebase pull" ``` `[cmd.<名字>]` 的全部字段: | 字段 | 默认 | 说明 | |---|---|---| | `cmd` | — | 真正执行的命令行 | | `help` | — | `gyx --help` 里那一行说明 | | `allow_all` | `false` | 允许不带仓名直接跑(= 全部仓); 不开则必须点名仓或组 | | `disable_async` | `false` | 串行执行并接管 tty, 给 `log` / `show` / `difftool` 这类要霸占终端的命令用 | | `shell` | `false` | 过一层 shell 而不是直接 exec(要用管道、`&&` 时开) | `[[repo]]` 除了 `name` / `path` 还有两个字段, 通常由命令写而不是手写: `flags`(插在 `git` 之后的额外参数, `gyx flags set` 写的就是它)和 `type`(自由文本, 目前只用来标记 bare 仓)。 ```toml # ~/.config/gyx/state.toml —— gyx 写的, 只有 diff [settings.colors] diverged = "b_red" # 你跑过 gyx color set diverged b_red [[repo]] name = "solver" path = "/home/me/src/solver" ``` **仓库和分组也可以钉在 `config.toml` 里**(比如想连注释一起进版本库): ```toml # 这几个仓在哪台机器上都要有 [[repo]] name = "dotfiles" path = "/home/me/dotfiles" ``` gyx 会照常识别它们, 但**删不掉** —— `state.toml` 叠在上面, 只能新增和覆盖, 没法表达"下面那层的某条没了". 碰到这种情况 gyx 会明确报错让你去改 `config.toml`: ``` $ gyx rm dotfiles gyx: repo "dotfiles" is declared in /home/me/.config/gyx/config.toml; remove it there instead ``` 改(而不是删)是可以的 —— `gyx flags set dotfiles ...` 会把改过的整条写进 `state.toml` 覆盖掉. 写入是**原子**的(临时文件 + rename)并带 flock, 并发的 `gyx add` 不会互相覆盖. ## 开发 ```sh go test ./... go test -race ./... # 并发是核心, 值得单独跑 gofmt -l . # CI 要求输出为空 go vet ./... ``` 测试分三层: 1. **纯函数**(不需要 git): porcelain v2 解析、`branch.ab` → 状态映射、commondir 解析、 显示宽度/截断、表格渲染、重名消歧、freeze 解析、gita 导入. 2. **集成**: 在 `t.TempDir()` 里真的 `git init`, 造出 dirty/staged/untracked/stashed/ ahead/behind/diverged/no-remote/detached/worktree/bare/空仓/悬空 submodule 各种状态, 断言采集结果. 3. **差分**: 拿 gita 跑同一批仓对比 `ll` 输出. 48 个仓的 `name|branch|symbols` 完全一致, 唯一的差异就是上面那个 worktree stash 的 bug. 测试不依赖机器上的 git 身份 —— helper 通过 `cmd.Env` 传 `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, 并把 `GIT_CONFIG_GLOBAL` 指到 `/dev/null`, 所以本机的 `~/.gitconfig` 不会影响结果. CI 跑的是 `.gitlab-ci.yml`(内网 GitLab, shell runner): gofmt / vet / test / 冒烟测试, 外加一个 `allow_failure` 的 race 检测 job(它要 cgo, 换了没有 C 工具链的 runner 时 降级成警告而不是挡住流水线). 打 `v*` tag 会交叉编译并发布. `.github/workflows/` 只对 GitHub 镜像有意义, 在 gitee 和内网 GitLab 上都不会跑. 改代码前建议先看 [AGENTS.md](AGENTS.md) 里的「必须知道的约束」—— 那几条都是踩过或差点踩到的坑. 设计与重写过程见 [doc/PLAN.md](doc/PLAN.md).