mirror of https://github.com/garrytan/gstack.git
109 lines
4.8 KiB
TypeScript
109 lines
4.8 KiB
TypeScript
// version-source — where a repo's version lives, and how wide it is.
|
|
//
|
|
// gstack's native shape is a plain-text VERSION file at the repo root holding a
|
|
// 4-digit MAJOR.MINOR.PATCH.MICRO. Two real-world shapes did not fit, and both failed
|
|
// CLOSED in a way that silently disabled /ship's version tooling:
|
|
//
|
|
// 1. The version's home is a package.json — often not at the root (a monorepo whose
|
|
// frontend/package.json is the single source of truth because the build injects it).
|
|
// The --version-path / .gstack/version-path pin already let you point anywhere, but
|
|
// the readers treated the target as raw text, so a JSON file parsed as "{" and every
|
|
// version read came back as the 0.0.0.0 fallback.
|
|
// 2. The version is 3-digit semver. parseVersion() required exactly four components, so
|
|
// gstack-next-version exited 2 ("could not parse base version") on every invocation —
|
|
// and that CLI *is* the queue-collision check, so /ship fell through to its documented
|
|
// "offline" path of naive local arithmetic. Two branches cut from the same base then
|
|
// pick the same version, and git merges that without a conflict because both sides set
|
|
// one line to identical text. The duplicate slot ships silently.
|
|
//
|
|
// Both are handled here rather than in each CLI so the two agree by construction.
|
|
//
|
|
// Detection is by shape, not configuration: a version-path ending in .json is read as JSON
|
|
// (.version), anything else as trimmed text; a version string with three components stays
|
|
// three components through bumping and formatting. A repo with a root VERSION file and
|
|
// 4-digit versions sees no behaviour change.
|
|
|
|
export type Version = [number, number, number, number];
|
|
export type VersionWidth = 3 | 4;
|
|
export type Bump = "major" | "minor" | "patch" | "micro";
|
|
|
|
/** Parse 3- or 4-component versions. 3-digit pads to [a,b,c,0] so comparison stays uniform. */
|
|
export function parseVersion(s: string): Version | null {
|
|
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$/);
|
|
if (!m) return null;
|
|
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0)];
|
|
}
|
|
|
|
/** How many components the string actually had — what to format back out as. */
|
|
export function versionWidth(s: string): VersionWidth {
|
|
return /^\d+\.\d+\.\d+\.\d+$/.test(s.trim()) ? 4 : 3;
|
|
}
|
|
|
|
export function fmtVersion(v: Version, width: VersionWidth = 4): string {
|
|
return v.slice(0, width).join(".");
|
|
}
|
|
|
|
export function cmpVersion(a: Version, b: Version): number {
|
|
for (let i = 0; i < 4; i++) {
|
|
if (a[i] !== b[i]) return a[i] - b[i];
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Bump one level. In a 3-digit repo there is no MICRO component to move, so `micro` is
|
|
* carried out as a PATCH: /ship auto-picks MICRO by default, and erroring there would make
|
|
* it unusable in every 3-digit repo — a silent no-op would be worse still, since the
|
|
* caller would then write back the version it started with and claim a taken slot.
|
|
*/
|
|
export function bumpVersion(v: Version, level: Bump, width: VersionWidth = 4): Version {
|
|
const effective: Bump = width === 3 && level === "micro" ? "patch" : level;
|
|
switch (effective) {
|
|
case "major":
|
|
return [v[0] + 1, 0, 0, 0];
|
|
case "minor":
|
|
return [v[0], v[1] + 1, 0, 0];
|
|
case "patch":
|
|
return [v[0], v[1], v[2] + 1, 0];
|
|
case "micro":
|
|
return [v[0], v[1], v[2], v[3] + 1];
|
|
}
|
|
}
|
|
|
|
/** True when the effective bump differs from the one asked for (so callers can say so). */
|
|
export function bumpWasCoerced(level: Bump, width: VersionWidth): boolean {
|
|
return width === 3 && level === "micro";
|
|
}
|
|
|
|
/** A version-path pointing at a .json is read as JSON, not as raw text. */
|
|
export function isJsonVersionPath(versionPath: string): boolean {
|
|
return /\.json$/i.test(versionPath.trim());
|
|
}
|
|
|
|
/**
|
|
* Pull the version out of whatever the version-path resolves to. `text` is the file's
|
|
* contents from anywhere — local read, `git show`, or a base64-decoded API response — so
|
|
* every reader agrees on interpretation. Returns "" when there is no usable version, which
|
|
* callers map to their own fallback.
|
|
*/
|
|
export function extractVersion(text: string, versionPath: string): string {
|
|
if (!isJsonVersionPath(versionPath)) return text.replace(/[\r\n\s]/g, "");
|
|
try {
|
|
const parsed = JSON.parse(text) as { version?: unknown };
|
|
return typeof parsed?.version === "string" ? parsed.version.trim() : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a version back into a JSON file, preserving the rest of it. Deliberately
|
|
* key-order-preserving (JSON.parse/stringify keeps insertion order) and 2-space indented
|
|
* with a trailing newline, matching what package managers write.
|
|
*/
|
|
export function setVersionInJson(raw: string, version: string): string {
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
parsed.version = version;
|
|
return JSON.stringify(parsed, null, 2) + "\n";
|
|
}
|