Skip to content

Commit b406132

Browse files
committed
feat(tui): surface off-tag build identity in version badge
Bake git-derived build identity at build time via bun --define into both build and build:bin, exposing a single DISPLAY_VERSION source. LANDING_VERSION and CLI_VERSION_TEXT both consume it with a dev/test fallback to plain v<pkg.version>, so unbuilt runs stay byte-identical. The badge shows describe-style tag-N-g<hash> when past a tag, -dirty on a dirty tracked tree, +g<hash> when no tags exist, and plain v<version> on a clean tag or outside a git repo. The git short hash is pinned to a stable --short=7 so the same commit renders the same identity regardless of clone. LANDING_VERSION is a live re-export of DISPLAY_VERSION so tests mock it in-process without a child-process harness.
1 parent 8890609 commit b406132

10 files changed

Lines changed: 550 additions & 11 deletions

File tree

‎docs/IMPLEMENTATION.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,8 @@ Other wave items (read bounds, shell truncation, process-group kill, grep caps,
552552
## Build and Validation
553553

554554
```bash
555-
bun run build # bun build ./src/index.ts --outdir ./dist --target bun
555+
bun run build # bun build ./src/index.ts --outdir ./dist --target bun, stamping
556+
# the version badge via --define "$(bun scripts/build-info.ts)"
556557
bun run typecheck # tsc --noEmit
557558
bun test ./src ./tests
558559
```

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
"corbits": "./dist/corbits"
3030
},
3131
"scripts": {
32-
"build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' && bun scripts/copy-repo-plugins.ts",
33-
"build:bin": "bun build ./src/index.ts --compile --minify --sourcemap --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits && ([ \"$(uname -s)\" != Darwin ] || codesign -s - --force ./dist/corbits) && bun scripts/copy-repo-plugins.ts",
32+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' --define \"$(bun scripts/build-info.ts)\" && bun scripts/copy-repo-plugins.ts",
33+
"build:bin": "bun build ./src/index.ts --compile --minify --sourcemap --define process.env.NODE_ENV='\"production\"' --define \"$(bun scripts/build-info.ts)\" --outfile ./dist/corbits && ([ \"$(uname -s)\" != Darwin ] || codesign -s - --force ./dist/corbits) && bun scripts/copy-repo-plugins.ts",
3434
"typecheck": "tsc --noEmit",
3535
"test": "bun test ./src ./e2e ./evals ./scripts ./testkit --randomize --seed 424242",
3636
"test:paths": "bun scripts/test-paths.ts",

‎scripts/build-info.test.ts‎

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { spawnSync } from "node:child_process";
3+
import { join } from "node:path";
4+
5+
import pkg from "../package.json" with { type: "json" };
6+
import { withTempDir } from "../testkit/temporary-dirs.js";
7+
import {
8+
collectGitInfo,
9+
composeDisplayVersion,
10+
type GitInfo,
11+
type RunResult,
12+
} from "./build-info.js";
13+
14+
const repoRoot = join(import.meta.dir, "..");
15+
const scriptPath = join(repoRoot, "scripts", "build-info.ts");
16+
17+
const DISPLAY_VERSION_RE =
18+
/^v\d+\.\d+\.\d+(-\d+-g[0-9a-f]+)?(\+g[0-9a-f]+)?(-dirty)?$/;
19+
20+
describe("composeDisplayVersion", () => {
21+
test("on tag, clean tree -> plain version", () => {
22+
const info: GitInfo = {
23+
tag: "v0.3.36",
24+
count: 0,
25+
hash: "9af7e1e",
26+
dirty: false,
27+
};
28+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36");
29+
});
30+
31+
test("on tag, dirty tree -> -dirty suffix", () => {
32+
const info: GitInfo = {
33+
tag: "v0.3.36",
34+
count: 0,
35+
hash: "9af7e1e",
36+
dirty: true,
37+
};
38+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36-dirty");
39+
});
40+
41+
test("N commits past tag -> -N-g<hash>", () => {
42+
const info: GitInfo = {
43+
tag: "v0.3.36",
44+
count: 7,
45+
hash: "9af7e1e",
46+
dirty: false,
47+
};
48+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36-7-g9af7e1e");
49+
});
50+
51+
test("N commits past tag, dirty -> -N-g<hash>-dirty", () => {
52+
const info: GitInfo = {
53+
tag: "v0.3.36",
54+
count: 7,
55+
hash: "9af7e1e",
56+
dirty: true,
57+
};
58+
expect(composeDisplayVersion("0.3.36", info)).toBe(
59+
"v0.3.36-7-g9af7e1e-dirty",
60+
);
61+
});
62+
63+
test("no tags -> +g<hash>", () => {
64+
const info: GitInfo = {
65+
tag: null,
66+
count: null,
67+
hash: "9af7e1e",
68+
dirty: false,
69+
};
70+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36+g9af7e1e");
71+
});
72+
73+
test("no tags, dirty -> +g<hash>-dirty", () => {
74+
const info: GitInfo = {
75+
tag: null,
76+
count: null,
77+
hash: "9af7e1e",
78+
dirty: true,
79+
};
80+
expect(composeDisplayVersion("0.3.36", info)).toBe(
81+
"v0.3.36+g9af7e1e-dirty",
82+
);
83+
});
84+
85+
test("not a git repo / metadata unavailable -> plain version", () => {
86+
const info: GitInfo = {
87+
tag: null,
88+
count: null,
89+
hash: null,
90+
dirty: false,
91+
};
92+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36");
93+
});
94+
95+
test("tag present but commit count unavailable -> plain version", () => {
96+
const info: GitInfo = {
97+
tag: "v0.3.36",
98+
count: null,
99+
hash: "9af7e1e",
100+
dirty: false,
101+
};
102+
expect(composeDisplayVersion("0.3.36", info)).toBe("v0.3.36");
103+
});
104+
});
105+
106+
interface FakeSpec {
107+
stdout?: string;
108+
exitCode?: number;
109+
throws?: boolean;
110+
}
111+
112+
function fakeRun(specs: Record<string, FakeSpec>) {
113+
return (args: string[]): RunResult => {
114+
const spec = specs[args.join(" ")] ?? {};
115+
if (spec.throws) throw new Error(`git ${args.join(" ")} exploded`);
116+
return { stdout: spec.stdout ?? "", exitCode: spec.exitCode ?? 0 };
117+
};
118+
}
119+
120+
describe("collectGitInfo", () => {
121+
test("git repo, clean tree", () => {
122+
const run = fakeRun({
123+
"rev-parse --is-inside-work-tree": { stdout: "true" },
124+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
125+
"rev-list --count v0.3.36..HEAD": { stdout: "7" },
126+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
127+
"diff-index --quiet HEAD --": { exitCode: 0 },
128+
});
129+
expect(collectGitInfo("/repo", run)).toEqual({
130+
tag: "v0.3.36",
131+
count: 7,
132+
hash: "9af7e1e",
133+
dirty: false,
134+
});
135+
});
136+
137+
test("git repo, dirty tree (diff-index exits 1)", () => {
138+
const run = fakeRun({
139+
"rev-parse --is-inside-work-tree": { stdout: "true" },
140+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
141+
"rev-list --count v0.3.36..HEAD": { stdout: "7" },
142+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
143+
"diff-index --quiet HEAD --": { exitCode: 1 },
144+
});
145+
expect(collectGitInfo("/repo", run)).toEqual({
146+
tag: "v0.3.36",
147+
count: 7,
148+
hash: "9af7e1e",
149+
dirty: true,
150+
});
151+
});
152+
153+
test("git repo, exactly on tag", () => {
154+
const run = fakeRun({
155+
"rev-parse --is-inside-work-tree": { stdout: "true" },
156+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
157+
"rev-list --count v0.3.36..HEAD": { stdout: "0" },
158+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
159+
"diff-index --quiet HEAD --": { exitCode: 0 },
160+
});
161+
expect(collectGitInfo("/repo", run)).toEqual({
162+
tag: "v0.3.36",
163+
count: 0,
164+
hash: "9af7e1e",
165+
dirty: false,
166+
});
167+
});
168+
169+
test("no tags (describe throws) degrades tag/count, keeps hash", () => {
170+
const run = fakeRun({
171+
"rev-parse --is-inside-work-tree": { stdout: "true" },
172+
"describe --tags --match v* --abbrev=0": { throws: true },
173+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
174+
"diff-index --quiet HEAD --": { exitCode: 0 },
175+
});
176+
expect(collectGitInfo("/repo", run)).toEqual({
177+
tag: null,
178+
count: null,
179+
hash: "9af7e1e",
180+
dirty: false,
181+
});
182+
});
183+
184+
test("no tags (describe exits 128 with empty stdout) degrades tag/count, keeps hash", () => {
185+
const run = fakeRun({
186+
"rev-parse --is-inside-work-tree": { stdout: "true" },
187+
"describe --tags --match v* --abbrev=0": { exitCode: 128 },
188+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
189+
"diff-index --quiet HEAD --": { exitCode: 0 },
190+
});
191+
expect(collectGitInfo("/repo", run)).toEqual({
192+
tag: null,
193+
count: null,
194+
hash: "9af7e1e",
195+
dirty: false,
196+
});
197+
});
198+
199+
test("not a git repo (--is-inside-work-tree -> false)", () => {
200+
const run = fakeRun({
201+
"rev-parse --is-inside-work-tree": { stdout: "false" },
202+
});
203+
expect(collectGitInfo("/outside", run)).toEqual({
204+
tag: null,
205+
count: null,
206+
hash: null,
207+
dirty: false,
208+
});
209+
});
210+
211+
test("git missing (rev-parse exits non-zero) -> all null", () => {
212+
const run = fakeRun({
213+
"rev-parse --is-inside-work-tree": { exitCode: 128 },
214+
});
215+
expect(collectGitInfo("/outside", run)).toEqual({
216+
tag: null,
217+
count: null,
218+
hash: null,
219+
dirty: false,
220+
});
221+
});
222+
});
223+
224+
describe("build-info CLI", () => {
225+
test("outside a git repo prints a plain-version define", async () => {
226+
await withTempDir("build-info-not-git-", async (dir) => {
227+
const res = spawnSync(process.execPath, [scriptPath], {
228+
cwd: dir,
229+
encoding: "utf8",
230+
});
231+
expect(res.status).toBe(0);
232+
expect(res.stdout.trim()).toBe(
233+
`process.env.CORBITS_BUILD_INFO="v${pkg.version}"`,
234+
);
235+
});
236+
});
237+
238+
test("from the repo root prints a define matching the suffix regex", () => {
239+
const res = spawnSync(process.execPath, [scriptPath], {
240+
cwd: repoRoot,
241+
encoding: "utf8",
242+
});
243+
expect(res.status).toBe(0);
244+
const line = res.stdout.trim();
245+
expect(line.startsWith("process.env.CORBITS_BUILD_INFO=")).toBe(true);
246+
const value: string = JSON.parse(
247+
line.slice("process.env.CORBITS_BUILD_INFO=".length),
248+
);
249+
expect(value).toMatch(DISPLAY_VERSION_RE);
250+
});
251+
});

‎scripts/build-info.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { spawnSync } from "node:child_process";
2+
3+
import pkg from "../package.json" with { type: "json" };
4+
5+
/**
6+
* Build-time git metadata for the version badge. Run once by `bun
7+
* scripts/build-info.ts` (wired into `build` and `build:bin` through
8+
* `--define "$(bun scripts/build-info.ts)"`); the single stdout line is the
9+
* define token, e.g. `process.env.CORBITS_BUILD_INFO="v0.3.36-7-g9af7e1e"`.
10+
*
11+
* A build must never fail for metadata: every field degrades to null/false
12+
* and the script prints a stderr warning instead of throwing.
13+
*/
14+
15+
export interface GitInfo {
16+
tag: string | null;
17+
count: number | null;
18+
hash: string | null;
19+
dirty: boolean;
20+
}
21+
22+
export interface RunResult {
23+
stdout: string;
24+
exitCode: number;
25+
}
26+
27+
export function gitRunner(cwd: string, args: string[]): RunResult {
28+
const res = spawnSync("git", args, { cwd, encoding: "utf8" });
29+
return {
30+
stdout: typeof res.stdout === "string" ? res.stdout.trim() : "",
31+
exitCode: res.status ?? 1,
32+
};
33+
}
34+
35+
function safeRun(
36+
run: (args: string[]) => RunResult,
37+
args: string[],
38+
): RunResult | null {
39+
try {
40+
return run(args);
41+
} catch (err) {
42+
console.error(`[build-info] git ${args.join(" ")} failed: ${String(err)}`);
43+
return null;
44+
}
45+
}
46+
47+
/**
48+
* Gathers git metadata against `cwd`. Not a git repo (or git missing) yields
49+
* `{ tag: null, count: null, hash: null, dirty: false }`; individual
50+
* failures degrade just their own field.
51+
*/
52+
export function collectGitInfo(
53+
cwd: string,
54+
run: (args: string[]) => RunResult = (args) => gitRunner(cwd, args),
55+
): GitInfo {
56+
const inside = safeRun(run, ["rev-parse", "--is-inside-work-tree"]);
57+
if (inside === null || inside.stdout !== "true") {
58+
return { tag: null, count: null, hash: null, dirty: false };
59+
}
60+
61+
const tagResult = safeRun(run, [
62+
"describe",
63+
"--tags",
64+
"--match",
65+
"v*",
66+
"--abbrev=0",
67+
]);
68+
const tag =
69+
tagResult !== null && tagResult.stdout !== "" ? tagResult.stdout : null;
70+
71+
let count: number | null = null;
72+
if (tag !== null) {
73+
const countResult = safeRun(run, ["rev-list", "--count", `${tag}..HEAD`]);
74+
if (countResult !== null) {
75+
const parsed = Number.parseInt(countResult.stdout, 10);
76+
count = Number.isNaN(parsed) ? null : parsed;
77+
}
78+
}
79+
80+
const hashResult = safeRun(run, ["rev-parse", "--short=7", "HEAD"]);
81+
const hash =
82+
hashResult !== null && hashResult.stdout !== "" ? hashResult.stdout : null;
83+
84+
const diffResult = safeRun(run, ["diff-index", "--quiet", "HEAD", "--"]);
85+
const dirty = diffResult !== null && diffResult.exitCode === 1;
86+
87+
return { tag, count, hash, dirty };
88+
}
89+
90+
/**
91+
* Pure composition of the display version from the six SOLUTION_SCOPE §3
92+
* cases:
93+
* on tag, clean -> v0.3.36
94+
* on tag, dirty -> v0.3.36-dirty
95+
* past tag -> v0.3.36-7-g9af7e1e (+ -dirty)
96+
* no tags -> v0.3.36+g9af7e1e (+ -dirty)
97+
* metadata unavailable -> v0.3.36
98+
*/
99+
export function composeDisplayVersion(version: string, info: GitInfo): string {
100+
const base = `v${version}`;
101+
const dirty = info.dirty ? "-dirty" : "";
102+
if (info.tag !== null && info.count === 0) {
103+
return `${base}${dirty}`;
104+
}
105+
if (
106+
info.tag !== null &&
107+
info.count !== null &&
108+
info.count > 0 &&
109+
info.hash !== null
110+
) {
111+
return `${base}-${info.count}-g${info.hash}${dirty}`;
112+
}
113+
if (info.tag === null && info.hash !== null) {
114+
return `${base}+g${info.hash}${dirty}`;
115+
}
116+
return base;
117+
}
118+
119+
if (import.meta.main) {
120+
const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
121+
const display = composeDisplayVersion(version, collectGitInfo(process.cwd()));
122+
console.log(`process.env.CORBITS_BUILD_INFO=${JSON.stringify(display)}`);
123+
}

0 commit comments

Comments
 (0)