diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump
index 6f8f486eb..3ea62ced9 100755
--- a/bin/gstack-version-bump
+++ b/bin/gstack-version-bump
@@ -73,17 +73,30 @@ function argVal(args: string[], flag: string): string | undefined {
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
-/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */
-function resolveVersionPath(cwd: string, explicit?: string): string {
- if (explicit) return join(cwd, explicit);
+/**
+ * Resolve the version file's path RELATIVE to the repo root: --version-path,
+ * else .gstack/version-path, else "VERSION".
+ *
+ * The relative form is what matters — `git show origin/:` needs it
+ * (an absolute path is unusable there). Callers used to
+ * derive versionRel from the CLI flag alone (#2462), so a repo using the
+ * .gstack/version-path pin had its local (pinned) version compared against
+ * the BASE's root VERSION file: two different files. On a repo with no root
+ * VERSION the base then always read 0.0.0.0, making every branch look FRESH —
+ * and the pinned-JSON handling never engaged without the explicit flag.
+ * Resolving once, here, keeps base and current reads in step.
+ */
+function resolveVersionRel(cwd: string, explicit?: string): string {
+ if (explicit) return explicit.trim();
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
- const p = readFileSync(pin, "utf-8").trim();
- if (p) return join(cwd, p);
+ const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
+ if (p) return p;
}
- return join(cwd, "VERSION");
+ return "VERSION";
}
+
function readVersionFile(p: string, versionRel = "VERSION"): string {
try {
// extractVersion (#2501): a .json version-path is read as JSON (.version),
@@ -98,7 +111,7 @@ function readVersionFile(p: string, versionRel = "VERSION"): string {
/**
* Resolve the manifest path: --package-json-path, else
* .gstack/package-json-path, else "package.json" (#2531, mirrors
- * resolveVersionPath). A repo whose only Node package lives in a
+ * resolveVersionRel). A repo whose only Node package lives in a
* subdirectory (web/, app/, frontend/) has no ROOT package.json, so the
* old join(cwd, "package.json") reported pkgExists:false and every bump
* silently wrote VERSION alone — leaving the manifest to be edited by
@@ -219,8 +232,8 @@ function classifyState(
function cmdClassify(args: string[], cwd: string): void {
const base = argVal(args, "--base");
if (!base) fail("classify requires --base ", 2);
- const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
- const versionRel = argVal(args, "--version-path") ?? "VERSION";
+ const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
const current = readVersionFile(versionPath, versionRel);
const baseV = baseVersion(cwd, base!, versionRel);
// When the version-path IS a package.json (#2501), that file is the single
@@ -261,8 +274,8 @@ function cmdWrite(args: string[], cwd: string): void {
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
}
- const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
- const versionRel = argVal(args, "--version-path") ?? "VERSION";
+ const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
// A package.json version-path (#2501) is written in place, keeping the rest
// of the file intact — and it is the ONLY file written. Also syncing a root
@@ -325,8 +338,8 @@ function cmdWrite(args: string[], cwd: string): void {
}
function cmdRepair(args: string[], cwd: string): void {
- const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
- const versionRel = argVal(args, "--version-path") ?? "VERSION";
+ const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
// Nothing to repair when the version lives in a package.json (#2501): there
// is no second file to drift from, and classify never reports DRIFT_* for
// that shape.
diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts
index d76058581..0cd93e0c0 100644
--- a/test/gstack-version-bump.test.ts
+++ b/test/gstack-version-bump.test.ts
@@ -296,6 +296,104 @@ describe('package.json as the version source (monorepo, 3-digit, #2501)', () =>
});
});
+/**
+ * #2462: cmdClassify's current-version read resolved the .gstack/version-path
+ * pin, but versionRel — the repo-relative path fed to `git show
+ * origin/:` — came from the CLI flag alone. In a pinned repo with
+ * no --version-path flag, base and current therefore read DIFFERENT files:
+ * current from the pin, base from the root VERSION (which may not exist, so
+ * base always read 0.0.0.0 and every branch looked FRESH). The pin's
+ * repo-relative form now drives all three subcommands.
+ */
+describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
+ const mkPinned = (pinRel: string): string => {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-pin-'));
+ fs.mkdirSync(path.dirname(path.join(d, pinRel)), { recursive: true });
+ fs.mkdirSync(path.join(d, '.gstack'), { recursive: true });
+ fs.writeFileSync(path.join(d, '.gstack', 'version-path'), pinRel + '\n');
+ return d;
+ };
+
+ const commitBase = (d: string): void => {
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d });
+ execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d });
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: d });
+ execFileSync('git', ['add', '-A'], { cwd: d });
+ execFileSync('git', ['commit', '-qm', 'base'], { cwd: d });
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim();
+ fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
+ fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
+ };
+
+ test('classify reads base AND current from the SAME pinned plain-text file', () => {
+ const pinRel = 'sub/VERSION';
+ const d = mkPinned(pinRel);
+ fs.writeFileSync(path.join(d, pinRel), '1.4.0.0\n');
+ commitBase(d);
+ // Move the pinned file past base — NO root VERSION file exists at all.
+ fs.writeFileSync(path.join(d, pinRel), '1.5.0.0\n');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
+ // Before the fix: baseVersion read root VERSION → "0.0.0.0" and the
+ // branch misclassified as... current 1.5.0.0 vs base 0.0.0.0. The REAL
+ // base is the pinned file's committed value.
+ expect(out.baseVersion).toBe('1.4.0.0');
+ expect(out.currentVersion).toBe('1.5.0.0');
+ expect(out.state).toBe('ALREADY_BUMPED');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('classify engages the pinned package.json JSON handling without a flag', () => {
+ const pinRel = 'frontend/package.json';
+ const d = mkPinned(pinRel);
+ fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
+ commitBase(d);
+ const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
+ // Before the fix: versionRel="VERSION" → the pinned JSON was read as raw
+ // text → currentVersion "0.0.0.0", pkgExists false, base from a
+ // nonexistent root VERSION.
+ expect(out.state).toBe('FRESH');
+ expect(out.baseVersion).toBe('0.99.2');
+ expect(out.currentVersion).toBe('0.99.2');
+ expect(out.pkgExists).toBe(true);
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('write honors the pin: updates the pinned package.json in place, no root VERSION invented', () => {
+ const pinRel = 'frontend/package.json';
+ const d = mkPinned(pinRel);
+ fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: d }).toString());
+ expect(out).toEqual({ wrote: '0.99.3', versionPath: pinRel, packageJson: true, packageLock: false });
+ expect(JSON.parse(fs.readFileSync(path.join(d, pinRel), 'utf-8')).version).toBe('0.99.3');
+ // Before the fix, write treated versionRel as "VERSION" and overwrote the
+ // pinned JSON file with a bare "0.99.3\n", destroying the manifest.
+ expect(fs.existsSync(path.join(d, 'VERSION'))).toBe(false);
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('repair honors the pin: pinned package.json is a no-op single source', () => {
+ const pinRel = 'frontend/package.json';
+ const d = mkPinned(pinRel);
+ fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
+ const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
+ expect(out.repaired).toBeNull();
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ test('--version-path flag still overrides the pin', () => {
+ const d = mkPinned('sub/VERSION');
+ fs.writeFileSync(path.join(d, 'sub', 'VERSION'), '1.0.0.0\n');
+ fs.writeFileSync(path.join(d, 'OTHER_VERSION'), '2.0.0.0\n');
+ commitBase(d);
+ const out = JSON.parse(
+ execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', 'OTHER_VERSION'], { cwd: d }).toString(),
+ );
+ expect(out.currentVersion).toBe('2.0.0.0');
+ expect(out.baseVersion).toBe('2.0.0.0');
+ fs.rmSync(d, { recursive: true, force: true });
+ });
+});
+
describe('subdirectory manifest (no root package.json, #2531)', () => {
/**
* The layout this tool used to silently no-op on: the only Node package