difftab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhangwanli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # difftab
2
+
3
+ **See what your AI coding agent changed — in one tab.**
4
+
5
+ Run one command in your repo. A local page opens with your working-tree diff and
6
+ branch status — read-only. Close the tab and the process exits on its own.
7
+
8
+ [中文文档](README.zh-CN.md)
9
+
10
+ difftab is built for the moment right after an agent finishes (or while it is still
11
+ running): you want a quick look at the changes, not a review session. It shows the
12
+ same thing `git status` and `git diff HEAD` would, rendered as side-by-side diffs with
13
+ syntax highlighting, and it refreshes itself as the agent keeps writing. It never writes
14
+ to your repository — the core promise, enforced by two CI gates and
15
+ [described below](#never-writes-to-your-repository).
16
+
17
+ ## Install
18
+
19
+ Nothing to install — run it in any repository:
20
+
21
+ ```bash
22
+ npx difftab # pnpm users: pnpm dlx difftab
23
+ ```
24
+
25
+ The first run spends a few seconds downloading and unpacking the package; later runs come
26
+ from the cache.
27
+
28
+ If you reach for it after every agent run, install it once instead — an `npx` run spends
29
+ more time resolving which version to fetch than difftab spends starting up:
30
+
31
+ ```bash
32
+ npm i -g difftab # or: pnpm add -g difftab
33
+ ```
34
+
35
+ Requires **Node.js 22.0.0 or newer**. `dependencies` is empty: the backend uses only the
36
+ Node standard library, and the frontend is bundled at build time, so a global install
37
+ pulls in zero transitive packages.
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ cd /path/to/your/repo
43
+ difftab # or: npx difftab
44
+ ```
45
+
46
+ | Option | What it does |
47
+ |---|---|
48
+ | `--no-open` | print the URL instead of opening a browser |
49
+ | `-v`, `--version` | print the version and exit |
50
+ | `-h`, `--help` | print help and exit |
51
+
52
+ Running it again in the same repository reuses the running instance instead of starting a
53
+ second process.
54
+
55
+ ## What you get
56
+
57
+ - **Change list** — staged, unstaged and untracked files in three groups, plus conflicts
58
+ in their own group with both status letters. Untracked directories are expanded to
59
+ individual files rather than collapsed into a single `dir/` entry.
60
+ - **Diffs, lazily** — click a file to load its patch; nothing fetches the whole-repo diff.
61
+ Rendered with [diff2html](https://diff2html.xyz/) and highlight.js in a VS Code-like
62
+ theme, light and dark following your system appearance.
63
+ - **Branch status** — current branch and ahead/behind counts, "no upstream" when there is
64
+ none, and a label for detached HEAD or an in-progress rebase / merge / cherry-pick /
65
+ revert / bisect / `git am`.
66
+ - **Auto-refresh** — a file watcher pushes changes over SSE, so the page keeps up while
67
+ an agent is still writing. Where recursive watching would exhaust the machine-wide
68
+ inotify quota (Linux on Node < 24.14.0), it polls instead and says so in the UI; while
69
+ polling, editing an *untracked* file's contents does not refresh the page (an untracked
70
+ entry is a single line in `git status` that does not change when its content does).
71
+ - **Exits on its own** — 45 seconds after the last tab closes. Multiple tabs, page
72
+ reloads, sleep/wake and backgrounded tabs do not trigger it.
73
+
74
+ Edge cases are handled explicitly rather than left to fail: empty repositories (no
75
+ commits yet), detached HEAD, an interrupted rebase, linked worktrees, submodules, binary
76
+ files, files over 5 MB, renames (annotated with the old path and similarity), deleted
77
+ files, and paths containing spaces, quotes, CJK characters or emoji.
78
+
79
+ difftab is deliberately a viewer: no editing, no history, no blame, no review workflow.
80
+ The full out-of-scope list is in
81
+ [CONTRIBUTING.md](CONTRIBUTING.md#the-read-only-promise-is-not-negotiable) and
82
+ [`docs/spec.md`](docs/spec.md) §4.
83
+
84
+ ## Never writes to your repository
85
+
86
+ Not a best-effort claim. difftab never stages, commits, discards, pulls, pushes,
87
+ branches or stashes — it only ever runs read-only git commands. Two independent gates
88
+ enforce that on every CI run, on all three platforms:
89
+
90
+ 1. **Command allowlist.** The whole flow runs under `GIT_TRACE`, and every git
91
+ invocation the product makes — including subprocesses git spawns internally, such as
92
+ an accidental `gc` — is asserted to be one of `status` / `diff` / `rev-parse` /
93
+ `ls-files` / `version`. A positive assertion checks the trace is not empty, so the
94
+ allowlist can never pass against nothing.
95
+ 2. **`.git` is byte-for-byte unchanged.** The flow runs once against a read-only `.git`,
96
+ and once against a writable one with a before/after snapshot of every file (size,
97
+ mtime, content digest). A control run proves the snapshot would have caught a change.
98
+
99
+ The backend is also auditable by hand: `dist/server/main.js` ships unminified precisely
100
+ so you can read which git commands it runs.
101
+
102
+ ## Local security
103
+
104
+ The server binds `127.0.0.1` on a random port with a session token, and every request —
105
+ including SSE and static assets — passes three checks *before* anything else is decided:
106
+
107
+ - **`Host`** must be `127.0.0.1:<port>` or `localhost:<port>`. This, not the token, is the
108
+ real defence against DNS rebinding.
109
+ - **`Origin`**, when present, must be the server's own. No CORS headers are ever sent.
110
+ - **Token**, bound to this session's port, so a token that leaks to another localhost
111
+ service (cookies are scoped by host, not by port) cannot be replayed there.
112
+
113
+ The token arrives in the URL once and is immediately swapped for an `HttpOnly;
114
+ SameSite=Strict` cookie with a 302 that strips the query, so it does not linger in browser
115
+ history. Responses carry a strict CSP (`default-src 'none'`, plus `frame-ancestors` /
116
+ `base-uri` / `form-action`, which do not fall back to it), `Cache-Control: no-store` and
117
+ `X-Content-Type-Options: nosniff`. Static assets are served from an in-memory allowlist,
118
+ never by joining a request path onto a directory. There is no environment variable or code
119
+ branch that relaxes any of this.
120
+
121
+ ## Platform support
122
+
123
+ macOS, Windows and Linux are all supported and all tested. CI runs the full smoke suite
124
+ across three platforms × Node 22 / 24 / 26, plus dedicated jobs for global installation,
125
+ the Node version guard, and degrading to polling when the inotify quota is exhausted.
126
+ What CI cannot cover is in [Known limitations](#known-limitations), below.
127
+
128
+ ## Known limitations
129
+
130
+ - **The token is passed on a command line.** argv is readable by other users on the same
131
+ machine, so launching the browser opens a window of tens of milliseconds — one that
132
+ requires an attacker already logged in locally, polling in a tight loop. Closing it
133
+ properly conflicts with reusing a running instance, so it is revisited after 0.1.0. How
134
+ wide it is under `xdg-open` is deliberately *not* measured in CI: a headless number
135
+ would be reassuring and meaningless.
136
+ - **The browser actually appearing on a Windows or Linux desktop is not covered by CI.**
137
+ Runners have no desktop session, so the *choice* of `open` / `cmd /c start ""` /
138
+ `xdg-open` and its argv are asserted, but not the window. Both of these wait on a first
139
+ real user.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ pnpm install --frozen-lockfile # pnpm version is pinned by the packageManager field
145
+ pnpm build # frontend (Vite) + backend (tsdown)
146
+ node bin/difftab.js # run it against any repository
147
+ ```
148
+
149
+ [CONTRIBUTING.md](CONTRIBUTING.md) has the rest: the dev-server setup, the full list of
150
+ gates, and the handful of things in this codebase that fail silently rather than loudly.
151
+ Requirements and design live in [`docs/`](docs/README.md) and are the single source of
152
+ truth — they change before the code does.
153
+
154
+ ## License
155
+
156
+ [MIT](LICENSE)
@@ -0,0 +1,137 @@
1
+ # difftab
2
+
3
+ **一个标签页,看懂 AI 编码 Agent 改了哪些代码。**
4
+
5
+ 在仓库目录敲一条命令。本地网页随即打开,只读展示当前工作区的 diff 与分支状态。
6
+ 关掉标签页,进程自行退出。
7
+
8
+ [English](README.md)
9
+
10
+ difftab 面向的是 agent 刚跑完(或者还在跑)的那一刻:你想「瞥一眼」改了什么,而不是
11
+ 开一场代码评审。它展示的就是 `git status` 与 `git diff HEAD` 的内容,以并排 diff 加语法
12
+ 高亮呈现,并在 agent 继续写的过程中自动刷新。它对你的仓库**全程零写操作**——这是核心承诺,
13
+ 由两道 CI 门禁守着,详见[下文](#全程零写操作)。
14
+
15
+ ## 安装
16
+
17
+ 不用装,在任意仓库目录里直接跑:
18
+
19
+ ```bash
20
+ npx difftab # pnpm 用户:pnpm dlx difftab
21
+ ```
22
+
23
+ 首次要下载解包,多花几秒,之后走缓存。
24
+
25
+ 如果你每次 agent 跑完都要看一眼,那就装一次——`npx` 光是决定该跑哪个版本,就比 difftab
26
+ 自己启动还花时间:
27
+
28
+ ```bash
29
+ npm i -g difftab # 或:pnpm add -g difftab
30
+ ```
31
+
32
+ 要求 **Node.js 22.0.0 或更高**。`dependencies` 为空:后端只用 Node 标准库,前端在构建期
33
+ 就打进产物,所以全局安装是零传递依赖的。
34
+
35
+ ## 使用
36
+
37
+ ```bash
38
+ cd /path/to/your/repo
39
+ difftab # 或:npx difftab
40
+ ```
41
+
42
+ | 参数 | 作用 |
43
+ |---|---|
44
+ | `--no-open` | 只打印 URL,不拉起浏览器 |
45
+ | `-v`, `--version` | 打印版本号并退出 |
46
+ | `-h`, `--help` | 打印帮助并退出 |
47
+
48
+ 在同一个仓库里再敲一次,会复用已在跑的实例,而不是起第二个进程。
49
+
50
+ ## 能看到什么
51
+
52
+ - **变更列表** —— 已暂存、未暂存、未跟踪三组,冲突文件自成一组并印出 XY 两位状态。未跟踪
53
+ 的目录展开到文件粒度,而不是折叠成一行 `dir/`。
54
+ - **按文件懒加载的 diff** —— 点开某个文件才取它的补丁,任何时候都不会去取整仓 diff。渲染
55
+ 用 [diff2html](https://diff2html.xyz/) + highlight.js,配色仿 VS Code,深浅两套跟随系统
56
+ 外观。
57
+ - **分支状态** —— 当前分支与 ahead/behind 计数,无上游时明说「无上游」;游离 HEAD 与进行
58
+ 中的 rebase / merge / cherry-pick / revert / bisect / `git am` 各出一个标注。
59
+ - **自动刷新** —— 文件监听经 SSE 推到前端,agent 边改你边看。在递归监听会耗尽整机
60
+ inotify 配额的场合(Linux + Node < 24.14.0),改用轮询,并在界面上标注出来;轮询期间改
61
+ 一个**未跟踪**文件的内容不会刷新页面(未跟踪条目在 `git status` 里只有一行,内容变了它
62
+ 一个字节都不变)。
63
+ - **自己退出** —— 最后一个标签页关掉 45 秒后自动退。多标签、刷新页面、系统休眠唤醒、浏览
64
+ 器丢弃后台标签都不会误触发。
65
+
66
+ 边界情况是被显式处理的,而不是留着崩:空仓库(还没有提交)、游离 HEAD、停在中途的 rebase、
67
+ linked worktree、submodule、二进制文件、超过 5MB 的文件、重命名(标注完整旧路径与相似度)、
68
+ 删除的文件,以及路径里带空格、引号、中日韩文字或 emoji 的文件。
69
+
70
+ difftab 刻意只是个查看器:不编辑代码、不看历史、不做 blame、不承载评审流程。完整的不做
71
+ 清单在 [CONTRIBUTING.md](CONTRIBUTING.md#the-read-only-promise-is-not-negotiable) 与
72
+ [`docs/spec.md`](docs/spec.md) §4。
73
+
74
+ ## 全程零写操作
75
+
76
+ 不是「尽量」。difftab 不 stage、不 commit、不 discard、不 pull / push、不建分支、
77
+ 不 stash——它只发只读的 git 命令。这条由两道互相独立的门禁在每次 CI、三个平台上守着:
78
+
79
+ 1. **命令白名单。** 整条流程跑在 `GIT_TRACE` 之下,产品发出的每一次 git 调用——包括 git
80
+ 自己内部再起的子进程(比如一次意外触发的 `gc`)——都被断言只能是 `status` / `diff` /
81
+ `rev-parse` / `ls-files` / `version`。另配一条「确实记到了东西」的正面断言,白名单不会
82
+ 对着一个空数组通过。
83
+ 2. **`.git` 逐字节不变。** 同一条流程跑两遍:一遍对着只读的 `.git`,一遍对着可写的
84
+ `.git` 并在前后各拍一次快照(每个文件的 size、mtime、内容摘要)比对。另有一组正面对照
85
+ 证明这份快照真的抓得住变化。
86
+
87
+ 后端也支持手工审计:`dist/server/main.js` **不压缩不混淆**发布,就是为了让你自己读一遍它
88
+ 到底跑了哪些 git 命令。
89
+
90
+ ## 本地安全
91
+
92
+ 服务绑定 `127.0.0.1`,随机端口 + 会话级 token。每一个请求——包括 SSE 与静态资源——都要先
93
+ 过三道校验,**排在其余一切判定之前**:
94
+
95
+ - **`Host`** 必须是 `127.0.0.1:<端口>` 或 `localhost:<端口>`。挡住 DNS rebinding 的是这一
96
+ 道,不是 token。
97
+ - **`Origin`** 非空时必须是服务自身。所有响应不带任何 CORS 头。
98
+ - **token** 与本次会话的端口绑定——cookie 的作用域是 host 而非 origin,不隔离端口,绑上
99
+ 端口后即使 token 漏给了同机另一个 localhost 服务,也没法在别处复用。
100
+
101
+ token 只在 URL 里出现一次,首访即置换成 `HttpOnly; SameSite=Strict` cookie 并 302 掉
102
+ query,不会长期滞留在浏览器历史里。响应带严格 CSP(`default-src 'none'`,外加不回退到它的
103
+ `frame-ancestors` / `base-uri` / `form-action`),以及 `Cache-Control: no-store` 与
104
+ `X-Content-Type-Options: nosniff`。静态资源按内存里的白名单映射,绝不用请求路径去拼目录。
105
+ **后端不存在任何放宽上述校验的环境变量或分支。**
106
+
107
+ ## 平台支持
108
+
109
+ macOS / Windows / Linux 三端均支持、均有测试。CI 在三平台 × Node 22 / 24 / 26 上跑完整冒烟
110
+ 套件,另有全局安装、Node 版本守卫、inotify 配额耗尽降级三个专用作业。CI 覆盖不到的部分在
111
+ 下面的[已知边界](#已知边界)。
112
+
113
+ ## 已知边界
114
+
115
+ - **token 要经过一次命令行。** argv 对同机其他用户可读,所以拉起浏览器时有一个几十毫秒的
116
+ 窗口——而它要求攻击者已经以另一个本机用户身份在紧循环轮询。真正关掉它与「再敲一次命令
117
+ 复用已有实例」相冲突,留到 0.1.0 之后再看。这个窗口在 `xdg-open` 下有多宽**刻意不在 CI
118
+ 里量**:headless 上量出来的数字会让人放心,但没有意义。
119
+ - **浏览器在 Windows / Linux 桌面上真的弹出来,CI 覆盖不到。** runner 没有桌面会话,所以
120
+ `open` / `cmd /c start ""` / `xdg-open` 的**选择与 argv** 有断言,窗口本身没有。这两条
121
+ 都等首个真实用户。
122
+
123
+ ## 开发
124
+
125
+ ```bash
126
+ pnpm install --frozen-lockfile # pnpm 版本由 packageManager 字段固定
127
+ pnpm build # 前端 Vite + 后端 tsdown
128
+ node bin/difftab.js # 在任意 git 仓库目录下跑起来
129
+ ```
130
+
131
+ 其余在 [CONTRIBUTING.md](CONTRIBUTING.md):dev server 怎么起、完整的门禁清单,以及这个仓库
132
+ 里那几条「坏了不报错」的地方。需求与设计的唯一事实来源是 [`docs/`](docs/README.md)——需求要
133
+ 变,先改 docs 再改代码。
134
+
135
+ ## License
136
+
137
+ [MIT](LICENSE)
package/bin/difftab.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // difftab CLI 入口 —— 版本守卫。
3
+ //
4
+ // 本文件手写维护,不参与 TypeScript 编译、不作为任何打包入口(spec §5.1 / §10)。
5
+ // 一旦它进了构建管线,就可能被注入超出 Node 22 的语法或被合并进主模块,
6
+ // 低于下限的用户拿到的将是解析期 SyntaxError —— 守卫在解析期即失效。
7
+ //
8
+ // 因此这里只用「保守语法」:ES2015 之内,不使用可选链 / 空值合并 /
9
+ // 顶层 await / class 字段等更晚的语法。守卫通过后,才动态 import 主模块。
10
+ //
11
+ // `node:fs` 是**内建模块**,静态 import 它不违反上面那条 —— 守卫要防的是「解析期就
12
+ // 炸掉」与「把主模块的新语法拖进来」,而内建模块两条都不沾,且在 Node 12 起就支持
13
+ // `node:` 前缀。写成静态 import 而不是在分支里动态 import:那条路要么 `await`
14
+ // (顶层 await 正是禁项),要么把报错塞进 `.then()` 回调里,而回调里的 `process.exit()`
15
+ // 与本文件要修的问题是同一类。
16
+
17
+ import { writeSync } from 'node:fs';
18
+
19
+ /**
20
+ * 退出前的报错**一律 `writeSync(2, …)`**(spec §5.8 红线)。
21
+ *
22
+ * `process.stderr.write(…)` + `process.exit()` 在 Windows 上写**管道**时是异步的,
23
+ * 整条消息会被丢掉 —— 症状是 stderr 全空、只剩一个退出码。而**文件重定向看不出
24
+ * 区别**(那条是同步写),所以门禁那侧必须经管道取回 stderr,见 CI 的 old-node-guard。
25
+ *
26
+ * try/catch 是因为读端可能先走(`difftab | head -1`):此刻要报的是别的事,
27
+ * 不该被一个 EPIPE 顶掉。
28
+ */
29
+ function writeStderr(message) {
30
+ try {
31
+ writeSync(2, message);
32
+ } catch (_cause) {
33
+ // 报不出来就算了 —— 退出码仍然是对的。
34
+ // 绑定写成 `_cause` 而不是省略:可选 catch 绑定是 ES2019,本文件只用 ES2015
35
+ }
36
+ }
37
+
38
+ // 下限只写一次。写成两个常量时,把 MIN_MAJOR 提到 24 却忘了改 MIN_VERSION,
39
+ // 结果是守卫正确地拒掉 Node 22、却告诉用户「requires Node.js 22.0.0 or newer,
40
+ // but this is v22.5.0」—— 没有任何门禁会因此变红。
41
+ // 与 package.json 的 engines.node 对齐由冒烟测试断言。
42
+ const MIN_MAJOR = 22;
43
+ const MIN_VERSION = `${MIN_MAJOR}.0.0`;
44
+
45
+ const raw = process.versions.node;
46
+ const major = parseInt(raw.split('.')[0], 10);
47
+
48
+ // 写成 !(major >= MIN_MAJOR) 而非 major < MIN_MAJOR:解析失败得到 NaN 时同样报错。
49
+ if (!(major >= MIN_MAJOR)) {
50
+ writeStderr(
51
+ 'difftab: requires Node.js ' +
52
+ MIN_VERSION +
53
+ ' or newer, but this is v' +
54
+ raw +
55
+ '.\n' +
56
+ 'Please upgrade Node.js and try again: https://nodejs.org/\n',
57
+ );
58
+ process.exit(1);
59
+ }
60
+
61
+ import('../dist/server/main.js')
62
+ .then((mod) => mod.main(process.argv.slice(2)))
63
+ .catch((err) => {
64
+ const detail = err && err.stack ? err.stack : String(err);
65
+ writeStderr(`difftab: failed to start.\n${detail}\n`);
66
+ process.exit(1);
67
+ });