Skip to content

Commit c55490d

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 a86b77c commit c55490d

8 files changed

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

‎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 & 5 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,10 +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${
761-
typeof pkg.version === "string" ? pkg.version : "0.0.0"
762-
}`;
759+
/** Printed for `corbits --version` / `-V`. Matches the display version (`src/version.ts`). */
760+
export const CLI_VERSION_TEXT = `${COMMAND_NAME} ${DISPLAY_VERSION}`;
763761

764762
/**
765763
* Thrown when the operator asked for the CLI version. Entry points must print

0 commit comments

Comments
 (0)