Skip to content

Commit fb9b903

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 fb9b903

8 files changed

Lines changed: 518 additions & 9 deletions

File tree

‎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: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
96+
interface FakeSpec {
97+
stdout?: string;
98+
exitCode?: number;
99+
throws?: boolean;
100+
}
101+
102+
function fakeRun(specs: Record<string, FakeSpec>) {
103+
return (args: string[]): RunResult => {
104+
const spec = specs[args.join(" ")] ?? {};
105+
if (spec.throws) throw new Error(`git ${args.join(" ")} exploded`);
106+
return { stdout: spec.stdout ?? "", exitCode: spec.exitCode ?? 0 };
107+
};
108+
}
109+
110+
describe("collectGitInfo", () => {
111+
test("git repo, clean tree", () => {
112+
const run = fakeRun({
113+
"rev-parse --is-inside-work-tree": { stdout: "true" },
114+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
115+
"rev-list --count v0.3.36..HEAD": { stdout: "7" },
116+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
117+
"diff-index --quiet HEAD --": { exitCode: 0 },
118+
});
119+
expect(collectGitInfo("/repo", run)).toEqual({
120+
tag: "v0.3.36",
121+
count: 7,
122+
hash: "9af7e1e",
123+
dirty: false,
124+
});
125+
});
126+
127+
test("git repo, dirty tree (diff-index exits 1)", () => {
128+
const run = fakeRun({
129+
"rev-parse --is-inside-work-tree": { stdout: "true" },
130+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
131+
"rev-list --count v0.3.36..HEAD": { stdout: "7" },
132+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
133+
"diff-index --quiet HEAD --": { exitCode: 1 },
134+
});
135+
expect(collectGitInfo("/repo", run)).toEqual({
136+
tag: "v0.3.36",
137+
count: 7,
138+
hash: "9af7e1e",
139+
dirty: true,
140+
});
141+
});
142+
143+
test("git repo, exactly on tag", () => {
144+
const run = fakeRun({
145+
"rev-parse --is-inside-work-tree": { stdout: "true" },
146+
"describe --tags --match v* --abbrev=0": { stdout: "v0.3.36" },
147+
"rev-list --count v0.3.36..HEAD": { stdout: "0" },
148+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
149+
"diff-index --quiet HEAD --": { exitCode: 0 },
150+
});
151+
expect(collectGitInfo("/repo", run)).toEqual({
152+
tag: "v0.3.36",
153+
count: 0,
154+
hash: "9af7e1e",
155+
dirty: false,
156+
});
157+
});
158+
159+
test("no tags (describe throws) degrades tag/count, keeps hash", () => {
160+
const run = fakeRun({
161+
"rev-parse --is-inside-work-tree": { stdout: "true" },
162+
"describe --tags --match v* --abbrev=0": { throws: true },
163+
"rev-parse --short=7 HEAD": { stdout: "9af7e1e" },
164+
"diff-index --quiet HEAD --": { exitCode: 0 },
165+
});
166+
expect(collectGitInfo("/repo", run)).toEqual({
167+
tag: null,
168+
count: null,
169+
hash: "9af7e1e",
170+
dirty: false,
171+
});
172+
});
173+
174+
test("not a git repo (--is-inside-work-tree -> false)", () => {
175+
const run = fakeRun({
176+
"rev-parse --is-inside-work-tree": { stdout: "false" },
177+
});
178+
expect(collectGitInfo("/outside", run)).toEqual({
179+
tag: null,
180+
count: null,
181+
hash: null,
182+
dirty: false,
183+
});
184+
});
185+
186+
test("git missing (rev-parse exits non-zero) -> all null", () => {
187+
const run = fakeRun({
188+
"rev-parse --is-inside-work-tree": { exitCode: 128 },
189+
});
190+
expect(collectGitInfo("/outside", run)).toEqual({
191+
tag: null,
192+
count: null,
193+
hash: null,
194+
dirty: false,
195+
});
196+
});
197+
});
198+
199+
describe("build-info CLI", () => {
200+
test("outside a git repo prints a plain-version define", async () => {
201+
await withTempDir("build-info-not-git-", async (dir) => {
202+
const res = spawnSync(process.execPath, [scriptPath], {
203+
cwd: dir,
204+
encoding: "utf8",
205+
});
206+
expect(res.status).toBe(0);
207+
expect(res.stdout.trim()).toBe(
208+
`process.env.CORBITS_BUILD_INFO="v${pkg.version}"`,
209+
);
210+
});
211+
});
212+
213+
test("from the repo root prints a define matching the suffix regex", () => {
214+
const res = spawnSync(process.execPath, [scriptPath], {
215+
cwd: repoRoot,
216+
encoding: "utf8",
217+
});
218+
expect(res.status).toBe(0);
219+
const line = res.stdout.trim();
220+
expect(line.startsWith("process.env.CORBITS_BUILD_INFO=")).toBe(true);
221+
const value: string = JSON.parse(
222+
line.slice("process.env.CORBITS_BUILD_INFO=".length),
223+
);
224+
expect(value).toMatch(DISPLAY_VERSION_RE);
225+
});
226+
});

‎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+
}

‎src/config/index.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "../session/index.js";
1010
import { loadState } from "../session/state.js";
1111
import { COMMAND_NAME } from "../branding.js";
12-
import pkg from "../../package.json" with { type: "json" };
12+
import { DISPLAY_VERSION } from "../version.js";
1313

1414
import { isDirectorId } from "../agent/directors/registry.js";
1515
import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js";
@@ -756,8 +756,8 @@ export class CliHelpError extends Error {
756756
}
757757
}
758758

759-
/** Printed for `corbits --version` / `-V`. Matches `package.json` version. */
760-
export const CLI_VERSION_TEXT = `${COMMAND_NAME} v${pkg.version}`;
759+
/** Printed for `corbits --version` / `-V`. Matches the display version (`src/version.ts`). */
760+
export const CLI_VERSION_TEXT = `${COMMAND_NAME} ${DISPLAY_VERSION}`;
761761

762762
/**
763763
* Thrown when the operator asked for the CLI version. Entry points must print

0 commit comments

Comments
 (0)